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.
- package/CHANGELOG.md +56 -0
- package/docs/04-render-ve-sablonlar.md +20 -9
- package/docs/05-islands.md +8 -4
- package/docs/06-cache.md +18 -16
- package/docs/07-yapilandirma.md +8 -7
- package/docs/en/04-rendering.md +21 -9
- package/docs/en/05-islands.md +8 -4
- package/docs/en/06-caching.md +18 -16
- package/docs/en/07-configuration.md +8 -6
- package/package.json +1 -1
- package/src/build/build.mjs +23 -8
- package/src/build/tasks/precompress.mjs +51 -11
- package/src/client/registry.js +13 -3
- package/src/compile/codegen.js +70 -4
- package/src/compile/compile-all.js +18 -3
- package/src/compile/index.js +6 -1
- package/src/config/defaults.js +9 -5
- package/src/config/index.js +3 -3
- package/src/http/request-cache.js +19 -1
- package/src/index.js +2 -0
- package/src/server/assets.js +51 -22
- package/src/server/cache-blob.js +33 -0
- package/src/server/create-app.js +5 -0
- package/src/server/data-cache.js +77 -11
- package/src/server/disk-cache.js +2 -2
- package/src/server/head-hints.js +21 -9
- package/src/server/html-cache.js +169 -39
- package/src/server/image-optimizer.js +72 -28
- package/src/server/redis.js +2 -2
- package/src/server/render.js +183 -60
- package/src/templates/layout.jsk +4 -4
- package/src/templates/layout.render.js +2 -21
- package/src/views/components/loader.js +11 -6
- package/src/views/helpers/html.js +13 -5
- package/src/views/helpers/tags.js +21 -4
- package/types/client/registry.d.ts +2 -0
- package/types/compile/codegen.d.ts +9 -4
- package/types/compile/compile-all.d.ts +3 -1
- package/types/compile/index.d.ts +1 -1
- package/types/config/defaults.d.ts +8 -4
- package/types/config/index.d.ts +1 -0
- package/types/index.d.ts +1 -1
- package/types/server/cache-blob.d.ts +8 -0
- package/types/server/data-cache.d.ts +1 -0
- package/types/server/html-cache.d.ts +4 -5
- package/types/server/render.d.ts +19 -0
package/src/server/disk-cache.js
CHANGED
|
@@ -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 {
|
|
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
|
|
140
|
+
return decodeCacheValueAsync(framed.body);
|
|
141
141
|
} catch {
|
|
142
142
|
return null;
|
|
143
143
|
}
|
package/src/server/head-hints.js
CHANGED
|
@@ -24,15 +24,27 @@ let cached = null;
|
|
|
24
24
|
export function preconnectHints() {
|
|
25
25
|
if (cached != null) return cached;
|
|
26
26
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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;
|
package/src/server/html-cache.js
CHANGED
|
@@ -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`
|
|
61
|
-
*
|
|
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
|
|
341
|
-
//
|
|
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")
|
|
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
|
-
|
|
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
|
-
*
|
|
526
|
-
*
|
|
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
|
-
//
|
|
619
|
-
//
|
|
620
|
-
const
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
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 (
|
|
645
|
-
|
|
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ı
|
|
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
|
-
|
|
1139
|
-
|
|
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
|
-
|
|
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 (
|
|
178
|
-
|
|
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
|
-
|
|
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
|
-
|
|
359
|
-
if (!
|
|
360
|
-
|
|
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
|
-
|
|
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
|
*
|
package/src/server/redis.js
CHANGED
|
@@ -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 {
|
|
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 :
|
|
298
|
+
return raw == null ? null : await decodeCacheValueAsync(raw);
|
|
299
299
|
} catch (error) {
|
|
300
300
|
noteFailure("get", error);
|
|
301
301
|
return null;
|