jskelet 0.7.0 → 0.7.2

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.
Files changed (46) hide show
  1. package/CHANGELOG.md +56 -0
  2. package/docs/04-render-ve-sablonlar.md +20 -9
  3. package/docs/05-islands.md +8 -4
  4. package/docs/06-cache.md +18 -16
  5. package/docs/07-yapilandirma.md +8 -7
  6. package/docs/en/04-rendering.md +21 -9
  7. package/docs/en/05-islands.md +8 -4
  8. package/docs/en/06-caching.md +18 -16
  9. package/docs/en/07-configuration.md +8 -6
  10. package/package.json +1 -1
  11. package/src/build/build.mjs +23 -8
  12. package/src/build/tasks/precompress.mjs +51 -11
  13. package/src/client/registry.js +13 -3
  14. package/src/compile/codegen.js +70 -4
  15. package/src/compile/compile-all.js +18 -3
  16. package/src/compile/index.js +6 -1
  17. package/src/config/defaults.js +9 -5
  18. package/src/config/index.js +3 -3
  19. package/src/http/request-cache.js +19 -1
  20. package/src/index.js +2 -0
  21. package/src/server/assets.js +51 -22
  22. package/src/server/cache-blob.js +33 -0
  23. package/src/server/create-app.js +5 -0
  24. package/src/server/data-cache.js +77 -11
  25. package/src/server/disk-cache.js +2 -2
  26. package/src/server/head-hints.js +21 -9
  27. package/src/server/html-cache.js +169 -39
  28. package/src/server/image-optimizer.js +72 -28
  29. package/src/server/redis.js +2 -2
  30. package/src/server/render.js +183 -60
  31. package/src/templates/layout.jsk +4 -4
  32. package/src/templates/layout.render.js +2 -21
  33. package/src/views/components/loader.js +11 -6
  34. package/src/views/helpers/html.js +13 -5
  35. package/src/views/helpers/tags.js +21 -4
  36. package/types/client/registry.d.ts +2 -0
  37. package/types/compile/codegen.d.ts +9 -4
  38. package/types/compile/compile-all.d.ts +3 -1
  39. package/types/compile/index.d.ts +1 -1
  40. package/types/config/defaults.d.ts +8 -4
  41. package/types/config/index.d.ts +1 -0
  42. package/types/index.d.ts +1 -1
  43. package/types/server/cache-blob.d.ts +8 -0
  44. package/types/server/data-cache.d.ts +1 -0
  45. package/types/server/html-cache.d.ts +4 -5
  46. package/types/server/render.d.ts +19 -0
@@ -14,7 +14,7 @@ import fs from "node:fs/promises";
14
14
  import path from "node:path";
15
15
  import { getConfig } from "../config/index.js";
16
16
  import { getBuildId } from "./assets.js";
17
- import { decodeCacheValue, encodeCacheValue } from "./cache-blob.js";
17
+ import { decodeCacheValueAsync, encodeCacheValue } from "./cache-blob.js";
18
18
  import { redisShares } from "./redis.js";
19
19
 
20
20
  /** @type {string | null} `null` → config'den çöz. Testler mutlak yol verir. */
@@ -137,7 +137,7 @@ export async function diskGetJson(kind, key) {
137
137
  try {
138
138
  const framed = unframe(await fs.readFile(file));
139
139
  if (!framed) return null;
140
- return decodeCacheValue(framed.body);
140
+ return decodeCacheValueAsync(framed.body);
141
141
  } catch {
142
142
  return null;
143
143
  }
@@ -24,15 +24,27 @@ let cached = null;
24
24
  export function preconnectHints() {
25
25
  if (cached != null) return cached;
26
26
 
27
- cached = getConfig()
28
- .preconnect.map((origin) => {
29
- try {
30
- return `<link rel="preconnect" href="${esc(new URL(origin).origin)}">`;
31
- } catch {
32
- console.warn(`[head] invalid preconnect URL, skipped: ${origin}`);
33
- return "";
34
- }
35
- })
27
+ /** @type {Set<string>} */
28
+ const origins = new Set();
29
+
30
+ for (const origin of getConfig().preconnect) {
31
+ try {
32
+ origins.add(new URL(origin).origin);
33
+ } catch {
34
+ console.warn(`[head] invalid preconnect URL, skipped: ${origin}`);
35
+ }
36
+ }
37
+
38
+ // CDN asset prefix config'e yazılmadan da preconnect alsın.
39
+ try {
40
+ const prefix = getConfig().build?.assets?.prefix;
41
+ if (prefix) origins.add(new URL(prefix).origin);
42
+ } catch {
43
+ /* geçersiz prefix — config zaten uyarmış olabilir */
44
+ }
45
+
46
+ cached = [...origins]
47
+ .map((origin) => `<link rel="preconnect" href="${esc(origin)}">`)
36
48
  .join("");
37
49
 
38
50
  return cached;
@@ -39,6 +39,7 @@ import { collectDependencies } from "./cache-deps.js";
39
39
  import { compilePattern, matchPattern } from "../config/pattern.js";
40
40
  import { pathOfCacheKey, publicHost } from "./cache-vary.js";
41
41
  import { diskDrop, diskDropMatching, diskGetJson, diskSetJson, diskShares } from "./disk-cache.js";
42
+ import { encodeText } from "./middleware/compression.js";
42
43
  import {
43
44
  cacheKey,
44
45
  onCacheEvent,
@@ -57,8 +58,9 @@ import {
57
58
  * yine sorulur; cevabı bu alan taşıyor.
58
59
  *
59
60
  * `sharedEncodings`: Redis'e en son kaç sıkıştırılmış gövde yazıldığı.
60
- * `encoded` haritası yanıt yolunda (`sendHtml`) doluyor, yani yazma anında
61
- * boş; `storeEncoded` açıkken harita büyüdüğünde girdi yeniden paylaşılır.
61
+ * `encoded` yazmadan sonra arka planda brotli ile doldurulur (ve `sendHtml`
62
+ * ilk yanıtta da yazabilir); `storeEncoded` açıkken harita büyüdüğünde girdi
63
+ * yeniden paylaşılır.
62
64
  *
63
65
  * `produceMs`: son başarılı üretimin süresi. Erken tazeleme penceresi bundan
64
66
  * türetilir; Redis'ten gelen kopyada yoksa varsayılan kullanılır.
@@ -196,6 +198,19 @@ const purgedDeps = new Map();
196
198
 
197
199
  const MAX_PURGED_DEPS = 1000;
198
200
 
201
+ /**
202
+ * L2'de bulunamayan anahtarlar — kısa süre `readShared` atlanır (boş Redis/
203
+ * disk round-trip + timeout maliyeti). Yazınca düşer.
204
+ *
205
+ * @type {Map<string, number>}
206
+ */
207
+ const sharedMissUntil = new Map();
208
+
209
+ /** @type {number} */
210
+ const SHARED_MISS_TTL_MS = 2_000;
211
+
212
+ const MAX_SHARED_MISS = 2_000;
213
+
199
214
  /**
200
215
  * Erken tazeleme lead'i: son render süresinin 2 katı (en az 250 ms), TTL'in
201
216
  * yarısından fazla olamaz — kısa TTL'lerde sürekli refresh döngüsü olmasın.
@@ -337,9 +352,8 @@ function read(key) {
337
352
  store.delete(key);
338
353
  store.set(key, entry);
339
354
 
340
- // `encoded` yanıt yolunda dolduğu için yazma anında paylaşılamıyor. Kontrol
341
- // yalnızca `storeEncoded` açıkken yapılır; kapalıyken (varsayılan) bu satır
342
- // tek bir karşılaştırmaya bile girmez.
355
+ // `encoded` yanıt yolunda büyüyebilir (gzip vb.). Kontrol yalnızca
356
+ // `storeEncoded` açıkken yapılır; kapalıyken bu satır karşılaştırmaya girmez.
343
357
  if (redisSharesEncoded() && entry.encoded.size !== entry.sharedEncodings) {
344
358
  share(key, entry);
345
359
  }
@@ -404,20 +418,43 @@ function share(key, entry) {
404
418
  * @param {string} key
405
419
  * @returns {Promise<HtmlEntry | null>}
406
420
  */
421
+ /**
422
+ * @param {string} key
423
+ * @returns {void}
424
+ */
425
+ function rememberSharedMiss(key) {
426
+ if (sharedMissUntil.size >= MAX_SHARED_MISS) {
427
+ const oldest = sharedMissUntil.keys().next().value;
428
+ if (oldest !== undefined) sharedMissUntil.delete(oldest);
429
+ }
430
+ sharedMissUntil.set(key, Date.now() + SHARED_MISS_TTL_MS);
431
+ }
432
+
407
433
  async function readShared(key) {
408
434
  const onRedis = redisShares("html");
409
435
  const onDisk = diskShares("html");
410
436
  if (!onRedis && !onDisk) return null;
411
437
 
438
+ const missUntil = sharedMissUntil.get(key);
439
+ if (missUntil !== undefined) {
440
+ if (Date.now() < missUntil) return null;
441
+ sharedMissUntil.delete(key);
442
+ }
443
+
412
444
  const payload = onRedis
413
445
  ? await redisGetJson(cacheKey("html", key))
414
446
  : await diskGetJson("html", key);
415
- if (!payload || typeof payload.html !== "string") return null;
447
+ if (!payload || typeof payload.html !== "string") {
448
+ rememberSharedMiss(key);
449
+ return null;
450
+ }
416
451
  if (typeof payload.expiresAt !== "number" || Date.now() >= payload.expiresAt) {
417
452
  // Dosyanın TTL'i yok; süresi dolmuş kopya okununca silinir.
418
453
  if (onDisk) diskDrop("html", [key]);
454
+ rememberSharedMiss(key);
419
455
  return null;
420
456
  }
457
+ sharedMissUntil.delete(key);
421
458
 
422
459
  const deps = new Set(Array.isArray(payload.deps) ? payload.deps.map(String) : []);
423
460
  const storedAt = Number(payload.storedAt) || 0;
@@ -466,8 +503,9 @@ async function readShared(key) {
466
503
  * @param {number} ttlSeconds
467
504
  * @param {Set<string> | null} deps Render sırasında okunan veri anahtarları.
468
505
  * @param {number} [produceMs] Son üretimin süresi (ms).
506
+ * @returns {Promise<void>}
469
507
  */
470
- function write(key, value, ttlSeconds, deps = null, produceMs = DEFAULT_PRODUCE_MS) {
508
+ async function write(key, value, ttlSeconds, deps = null, produceMs = DEFAULT_PRODUCE_MS) {
471
509
  const now = Date.now();
472
510
 
473
511
  /** @type {HtmlEntry} */
@@ -487,7 +525,30 @@ function write(key, value, ttlSeconds, deps = null, produceMs = DEFAULT_PRODUCE_
487
525
 
488
526
  // Bütçeye sığmayan sayfa Redis'e de yazılmaz: bir sonraki istek onu
489
527
  // geri alıp yine reddeder, stringify ise o anki RSS'i şişirir.
490
- if (install(key, entry)) share(key, entry);
528
+ sharedMissUntil.delete(key);
529
+ if (!install(key, entry)) return;
530
+
531
+ // MISS yanıtı br ile gidebilsin: encode bitmeden dönme. share encode sonrası
532
+ // (storeEncoded açıkken br L2'ye de gider).
533
+ await preencodeEntry(key, entry);
534
+ share(key, entry);
535
+ }
536
+
537
+ /**
538
+ * Yazılan HTML için brotli üretir.
539
+ *
540
+ * @param {string} key
541
+ * @param {HtmlEntry} entry
542
+ * @returns {Promise<void>}
543
+ */
544
+ async function preencodeEntry(key, entry) {
545
+ try {
546
+ const buffer = await encodeText(entry.html, "br");
547
+ if (store.get(key) !== entry) return;
548
+ rememberHtmlEncoding(entry.encoded, "br", buffer);
549
+ } catch {
550
+ // Encode başarısızsa sonraki sendHtml dener; cache yazımı bozulmaz.
551
+ }
491
552
  }
492
553
 
493
554
  /**
@@ -522,10 +583,8 @@ function install(key, entry) {
522
583
  }
523
584
 
524
585
  /**
525
- * Sıkıştırılmış gövde `install()`'dan sonra, ilk brotli/gzip yanıtında
526
- * girdinin `encoded` haritasına eklenir. Sayacı delta ile büyütmek, o sıra
527
- * LRU'dan düşmüş bir haritaya yazınca bir daha inmeyen bir artık bırakır;
528
- * store'dan yeniden okumak o artığı taşımaz.
586
+ * Bayt sayacını store'dan yeniden hesaplar. Testler elle `encoded` doldurunca
587
+ * çağırır; hot path `rememberHtmlEncoding` delta kullanır.
529
588
  *
530
589
  * @returns {void}
531
590
  */
@@ -612,42 +671,94 @@ function extendStaleWhileRefreshing(key) {
612
671
  * @returns {Promise<{ html: string, status: number, degraded?: boolean,
613
672
  * storable?: boolean }>}
614
673
  */
674
+ /** L2 yanıtı bu sürede gelmezse local produce başlar (yavaş Redis TTFB'si). */
675
+ const SHARED_WAIT_MS = 50;
676
+
615
677
  async function produce(key, ttlSeconds, producer, token) {
616
678
  const startedAt = Date.now();
617
679
 
618
- // Başka bir node bu sayfayı zaten render ettiyse render hiç çalışmaz. Soğuk
619
- // ayağa kalkan bir instance'ın sıcak önbellek bulmasının tek yolu bu.
620
- const shared = await readShared(key);
621
- if (shared && tokens.get(key) === token) {
622
- install(key, shared);
623
- return { html: shared.html, status: shared.status };
680
+ // L2 ile yarış: hızlı HIT kullan; yavaşsa local üret. Local hata L2'yi
681
+ // iptal etmez — disk/Redis decode 50 ms'yi aşabilir.
682
+ const sharedPromise = readShared(key);
683
+ const timedOut = await Promise.race([
684
+ sharedPromise.then((shared) => ({ shared, timeout: false })),
685
+ sleep(SHARED_WAIT_MS).then(() => ({ shared: null, timeout: true })),
686
+ ]);
687
+
688
+ if (timedOut.shared && tokens.get(key) === token) {
689
+ install(key, timedOut.shared);
690
+ return { html: timedOut.shared.html, status: timedOut.shared.status };
624
691
  }
625
692
 
626
- // Bağımlılıklar tazelemede de toplanır, ilk üretimde değil sadece: sayfanın
627
- // okuduğu anahtarlar zamanla değişir (yeni bir widget, kaldırılan bir blok).
628
693
  const deps = trackDependencies() ? new Set() : null;
694
+ const runLocal = () =>
695
+ deps ? collectDependencies(deps, producer) : producer();
696
+
697
+ /** @type {{ html: string, status: number, degraded?: boolean, storable?: boolean } | null} */
698
+ let value = null;
699
+
700
+ if (timedOut.timeout) {
701
+ const localSettled = runLocal().then(
702
+ (v) => /** @type {const} */ ({ ok: true, value: v }),
703
+ (err) => /** @type {const} */ ({ ok: false, err }),
704
+ );
705
+
706
+ const winner = await Promise.race([
707
+ sharedPromise.then((shared) =>
708
+ shared
709
+ ? /** @type {const} */ ({ source: "shared", shared })
710
+ : /** @type {const} */ ({ source: "miss" }),
711
+ ),
712
+ localSettled.then((result) =>
713
+ /** @type {const} */ ({ source: "local", result }),
714
+ ),
715
+ ]);
716
+
717
+ if (winner.source === "shared" && tokens.get(key) === token) {
718
+ install(key, winner.shared);
719
+ return { html: winner.shared.html, status: winner.shared.status };
720
+ }
721
+
722
+ if (winner.source === "miss") {
723
+ const result = await localSettled;
724
+ if (!result.ok) throw result.err;
725
+ value = result.value;
726
+ } else {
727
+ // Local önce bitti; geç L2 HIT varsa onu tercih et (yazma yok).
728
+ const late = await sharedPromise;
729
+ if (late && tokens.get(key) === token) {
730
+ install(key, late);
731
+ return { html: late.html, status: late.status };
732
+ }
733
+ if (!winner.result.ok) throw winner.result.err;
734
+ value = winner.result.value;
735
+ }
736
+ } else {
737
+ // L2 miss (null) — local üret.
738
+ value = await runLocal();
739
+ }
629
740
 
630
- const value = await (deps ? collectDependencies(deps, producer) : producer());
631
-
632
- // `degraded`: upstream düştüğü için eksik veriyle üretilmiş HTML.
633
- // Saklanırsa eksik içerik tüm TTL boyunca servis edilir.
634
- //
635
- // `storable: false`: çıktı kullanıcıya bağlı (cookie/Authorization
636
- // okundu). Anahtar yalnızca yol + query olduğu için saklamak, bir
637
- // kullanıcının HTML'ini bir başkasına servis etmek olur. Paylaşımlı
638
- // kademede bunun bedeli daha da ağır — bir kullanıcının HTML'i tüm kümeye
639
- // dağılırdı — bu yüzden kontrol Redis yazımından önce, `write()` içinde.
640
- //
641
- // Token uyuşmuyorsa bu tur, sonucu geçersiz kılan bir invalidation'ın
642
- // öncesinde başlamış demektir; yazmak az önce düşürüleni geri koyardı.
643
741
  const valid = tokens.get(key) === token && !readsPurgedData(deps, startedAt);
644
- if (valid && value.status === 200 && !value.degraded && value.storable !== false) {
645
- write(key, value, ttlSeconds, deps, Date.now() - startedAt);
742
+ if (
743
+ value &&
744
+ valid &&
745
+ value.status === 200 &&
746
+ !value.degraded &&
747
+ value.storable !== false
748
+ ) {
749
+ await write(key, value, ttlSeconds, deps, Date.now() - startedAt);
646
750
  }
647
751
 
648
752
  return value;
649
753
  }
650
754
 
755
+ /** @param {number} ms */
756
+ function sleep(ms) {
757
+ return new Promise((resolve) => {
758
+ setTimeout(resolve, ms).unref?.();
759
+ });
760
+ }
761
+
651
762
  /**
652
763
  * @param {string} key
653
764
  * @param {number} ttlSeconds 0 → cache yok
@@ -705,6 +816,7 @@ function clearLocal() {
705
816
  tokens.clear();
706
817
  invalidated.clear();
707
818
  purgedDeps.clear();
819
+ sharedMissUntil.clear();
708
820
  }
709
821
 
710
822
  export function getHtmlCacheSize() {
@@ -1127,7 +1239,8 @@ export function isHtmlCacheKeyFresh(key) {
1127
1239
 
1128
1240
  /**
1129
1241
  * Girdide en fazla bir sıkıştırılmış gövde durur. Yeni kodlama eskisinin
1130
- * yerini alır; ham HTML kalır. Bayt sayacı `noteHtmlCacheGrowth` ile işlenir.
1242
+ * yerini alır; ham HTML kalır. Bayt sayacı delta ile güncellenir — harita
1243
+ * LRU'dan düşmüşse (store'da yok) sayaç oynatılmaz.
1131
1244
  *
1132
1245
  * @param {Map<string, Buffer>} encoded
1133
1246
  * @param {string} encoding
@@ -1135,11 +1248,28 @@ export function isHtmlCacheKeyFresh(key) {
1135
1248
  * @returns {void}
1136
1249
  */
1137
1250
  export function rememberHtmlEncoding(encoded, encoding, buffer) {
1138
- for (const key of [...encoded.keys()]) {
1139
- if (key !== encoding) encoded.delete(key);
1251
+ let removed = 0;
1252
+ for (const [key, buf] of [...encoded.entries()]) {
1253
+ if (key !== encoding) {
1254
+ removed += buf.length;
1255
+ encoded.delete(key);
1256
+ }
1140
1257
  }
1258
+ const previous = encoded.get(encoding);
1259
+ if (previous) removed += previous.length;
1141
1260
  encoded.set(encoding, buffer);
1142
- noteHtmlCacheGrowth();
1261
+
1262
+ let owned = false;
1263
+ for (const entry of store.values()) {
1264
+ if (entry.encoded === encoded) {
1265
+ owned = true;
1266
+ break;
1267
+ }
1268
+ }
1269
+ if (!owned) return;
1270
+
1271
+ storedBytes = Math.max(0, storedBytes - removed + buffer.length);
1272
+ evictOverflow();
1143
1273
  }
1144
1274
 
1145
1275
  /**
@@ -39,6 +39,17 @@ const imageCacheIndex = new Map();
39
39
  /** @type {number} */
40
40
  let imageCacheBytes = 0;
41
41
 
42
+ /** Aynı url+w+q için eşzamanlı optimize birleşir. @type {Map<string, Promise<void>>} */
43
+ const optimizeInflight = new Map();
44
+
45
+ /**
46
+ * DNS güven kontrolü kısa TTL'li cache — her hop'ta tekrar lookup olmasın.
47
+ * @type {Map<string, { ok: boolean, until: number }>}
48
+ */
49
+ const dnsSafeCache = new Map();
50
+ const DNS_SAFE_TTL_MS = 60_000;
51
+ const MAX_DNS_SAFE = 500;
52
+
42
53
  const MAX_REDIRECTS = 5;
43
54
 
44
55
  /**
@@ -172,16 +183,27 @@ export function isRemoteUrlShapeAllowed(url, allowHosts) {
172
183
  */
173
184
  export async function assertResolvedHostSafe(hostname) {
174
185
  if (isBlockedAddress(hostname)) return false;
186
+
187
+ const cached = dnsSafeCache.get(hostname);
188
+ if (cached && Date.now() < cached.until) return cached.ok;
189
+
190
+ /** @type {boolean} */
191
+ let ok = false;
175
192
  try {
176
193
  const results = await dns.lookup(hostname, { all: true, verbatim: true });
177
- if (!results.length) return false;
178
- for (const { address } of results) {
179
- if (isBlockedAddress(address)) return false;
194
+ if (results.length) {
195
+ ok = results.every(({ address }) => !isBlockedAddress(address));
180
196
  }
181
- return true;
182
197
  } catch {
183
- return false;
198
+ ok = false;
199
+ }
200
+
201
+ if (dnsSafeCache.size >= MAX_DNS_SAFE) {
202
+ const oldest = dnsSafeCache.keys().next().value;
203
+ if (oldest !== undefined) dnsSafeCache.delete(oldest);
184
204
  }
205
+ dnsSafeCache.set(hostname, { ok, until: Date.now() + DNS_SAFE_TTL_MS });
206
+ return ok;
185
207
  }
186
208
 
187
209
  /**
@@ -355,32 +377,23 @@ async function handleOptimize(req, res, remote) {
355
377
  return;
356
378
  }
357
379
 
358
- const upstream = await fetchUpstream(allowed.href, remote);
359
- if (!upstream.ok) {
360
- res.status(502).type("text").send("Upstream image fetch failed");
380
+ let pending = optimizeInflight.get(key);
381
+ if (!pending) {
382
+ pending = materializeOptimized(allowed.href, filePath, width, quality, remote).finally(
383
+ () => {
384
+ optimizeInflight.delete(key);
385
+ },
386
+ );
387
+ optimizeInflight.set(key, pending);
388
+ }
389
+ await pending;
390
+
391
+ if (imageCacheIndex.has(filePath) || fs.existsSync(filePath)) {
392
+ sendCached(res, filePath, remote.cacheMaxAge);
361
393
  return;
362
394
  }
363
395
 
364
- const sharp = sharpModule.default;
365
- const buffer = await sharp(upstream.buffer, { failOn: "none" })
366
- .rotate()
367
- .resize({
368
- width,
369
- withoutEnlargement: true,
370
- fit: "inside",
371
- })
372
- .webp({ quality, effort: 4 })
373
- .toBuffer();
374
-
375
- // Atomik yaz: yarım dosya immutable cache'e düşmesin.
376
- const tmp = `${filePath}.${process.pid}.tmp`;
377
- await fs.promises.writeFile(tmp, buffer);
378
- await fs.promises.rename(tmp, filePath);
379
- // Yeni dosya için yer aç: en eski düşer. Az önce yazılan, tek başına
380
- // tavanı aşıyorsa kalır; bir sonraki yazım onu düşürür. Silme, bu
381
- // yanıtın `sendFile`'ından önce olmasın.
382
- noteImageCacheFile(filePath, buffer.length);
383
- sendCached(res, filePath, remote.cacheMaxAge);
396
+ res.status(502).type("text").send("Upstream image fetch failed");
384
397
  } catch (error) {
385
398
  console.warn("[images.remote] optimize failed:", error);
386
399
  // Bozuk kaynakta sayfa boş kalmasın: orijinale düş.
@@ -388,6 +401,37 @@ async function handleOptimize(req, res, remote) {
388
401
  }
389
402
  }
390
403
 
404
+ /**
405
+ * @param {string} href
406
+ * @param {string} filePath
407
+ * @param {number} width
408
+ * @param {number} quality
409
+ * @param {import('../config/index.js').ImagesRemoteConfig} remote
410
+ * @returns {Promise<void>}
411
+ */
412
+ async function materializeOptimized(href, filePath, width, quality, remote) {
413
+ if (imageCacheIndex.has(filePath) || fs.existsSync(filePath)) return;
414
+
415
+ const upstream = await fetchUpstream(href, remote);
416
+ if (!upstream.ok) return;
417
+
418
+ const sharp = /** @type {NonNullable<typeof sharpModule>} */ (sharpModule).default;
419
+ const buffer = await sharp(upstream.buffer, { failOn: "none" })
420
+ .rotate()
421
+ .resize({
422
+ width,
423
+ withoutEnlargement: true,
424
+ fit: "inside",
425
+ })
426
+ .webp({ quality, effort: 4 })
427
+ .toBuffer();
428
+
429
+ const tmp = `${filePath}.${process.pid}.tmp`;
430
+ await fs.promises.writeFile(tmp, buffer);
431
+ await fs.promises.rename(tmp, filePath);
432
+ noteImageCacheFile(filePath, buffer.length);
433
+ }
434
+
391
435
  /**
392
436
  * Redirect'leri elle takip eder; her hop allowlist + DNS private kontrolünden geçer.
393
437
  *
@@ -22,7 +22,7 @@ import crypto from "node:crypto";
22
22
  import { DEFAULT_REDIS } from "../config/defaults.js";
23
23
  import { tryImportFromApp } from "../build/resolve-peer.mjs";
24
24
  import { getBuildId } from "./assets.js";
25
- import { decodeCacheValue, encodeCacheValue } from "./cache-blob.js";
25
+ import { decodeCacheValueAsync, encodeCacheValue } from "./cache-blob.js";
26
26
 
27
27
  /**
28
28
  * @typedef {import('../config/index.js').RedisConfig} RedisConfig
@@ -295,7 +295,7 @@ export async function redisGetJson(key) {
295
295
  const raw =
296
296
  typeof client.getBuffer === "function" ? await client.getBuffer(key) : await client.get(key);
297
297
  noteSuccess();
298
- return raw == null ? null : decodeCacheValue(raw);
298
+ return raw == null ? null : await decodeCacheValueAsync(raw);
299
299
  } catch (error) {
300
300
  noteFailure("get", error);
301
301
  return null;