jskelet 0.6.2 → 0.6.4

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 (43) hide show
  1. package/CHANGELOG.md +32 -0
  2. package/README.md +2 -0
  3. package/docs/02-mimari.md +1 -0
  4. package/docs/04-render-ve-sablonlar.md +9 -3
  5. package/docs/06-cache.md +53 -9
  6. package/docs/07-yapilandirma.md +1208 -1191
  7. package/docs/08-build.md +2 -1
  8. package/docs/10-dagitim.md +16 -6
  9. package/docs/12-panel-ve-oturum.md +2 -1
  10. package/docs/en/02-architecture.md +2 -1
  11. package/docs/en/04-rendering.md +9 -3
  12. package/docs/en/06-caching.md +54 -9
  13. package/docs/en/07-configuration.md +24 -9
  14. package/docs/en/08-build.md +2 -1
  15. package/docs/en/10-deployment.md +17 -6
  16. package/docs/en/12-dashboards-and-sessions.md +2 -1
  17. package/package.json +1 -1
  18. package/src/config/defaults.js +541 -518
  19. package/src/config/index.js +1500 -1456
  20. package/src/init.mjs +2 -0
  21. package/src/server/cache-blob.js +70 -0
  22. package/src/server/cache-control.js +45 -0
  23. package/src/server/data-cache.js +118 -27
  24. package/src/server/disk-cache.js +233 -0
  25. package/src/server/html-cache.js +90 -16
  26. package/src/server/image-optimizer.js +95 -2
  27. package/src/server/logs/file-sink.js +159 -32
  28. package/src/server/logs/pipeline.js +10 -3
  29. package/src/server/middleware/static-precompressed.js +31 -10
  30. package/src/server/og-image.js +17 -4
  31. package/src/server/prewarm.js +25 -1
  32. package/src/server/redis.js +31 -12
  33. package/src/server/render.js +910 -910
  34. package/types/config/defaults.d.ts +21 -1
  35. package/types/config/index.d.ts +14 -0
  36. package/types/server/cache-blob.d.ts +13 -0
  37. package/types/server/cache-control.d.ts +28 -0
  38. package/types/server/data-cache.d.ts +9 -0
  39. package/types/server/disk-cache.d.ts +36 -0
  40. package/types/server/html-cache.d.ts +26 -3
  41. package/types/server/logs/file-sink.d.ts +16 -5
  42. package/types/server/og-image.d.ts +5 -0
  43. package/types/server/redis.d.ts +2 -1
package/src/init.mjs CHANGED
@@ -30,6 +30,8 @@ export default {
30
30
  return {
31
31
  /** How long a page's HTML stays in the cache (seconds). */
32
32
  html: { "/": 60 },
33
+ /** Edge stale window after that TTL. 0 omits the directive. */
34
+ staleWhileRevalidate: 60,
33
35
  };
34
36
  },
35
37
 
@@ -0,0 +1,70 @@
1
+ /**
2
+ * Redis ve disk L2'nin ortak gövdesi.
3
+ *
4
+ * 1 KB altı düz JSON kalır (`redis-cli` ve küçük veri kayıtları okunaklı
5
+ * kalsın). Üstü `JSK\\x01` + brotli. zstd `node:zlib`'de 22.15'ten itibaren
6
+ * var; motor `>=22` olduğu için brotli — yanıt sıkıştırmasıyla aynı kalite.
7
+ */
8
+ import zlib from "node:zlib";
9
+
10
+ const COMPRESS_THRESHOLD = 1024;
11
+ const CACHE_MAGIC = Buffer.from([0x4a, 0x53, 0x4b, 0x01]);
12
+ const BROTLI_OPTIONS = {
13
+ params: {
14
+ [zlib.constants.BROTLI_PARAM_QUALITY]: 5,
15
+ [zlib.constants.BROTLI_PARAM_MODE]: zlib.constants.BROTLI_MODE_TEXT,
16
+ },
17
+ };
18
+
19
+ /**
20
+ * @param {string} json
21
+ * @param {Buffer} compressed
22
+ * @returns {string | Buffer}
23
+ */
24
+ function pickPayload(json, compressed) {
25
+ const packed = Buffer.concat([CACHE_MAGIC, compressed]);
26
+ return packed.length < Buffer.byteLength(json) ? packed : json;
27
+ }
28
+
29
+ /**
30
+ * Küçük gövde düz `string` döner (çağıran senkron yazabilsin). Büyük gövde
31
+ * brotli bitince çözülen bir Promise.
32
+ *
33
+ * @param {unknown} value
34
+ * @returns {string | Buffer | Promise<string | Buffer> | null}
35
+ */
36
+ export function encodeCacheValue(value) {
37
+ /** @type {string} */
38
+ let json;
39
+ try {
40
+ json = JSON.stringify(value);
41
+ } catch {
42
+ return null;
43
+ }
44
+
45
+ if (Buffer.byteLength(json) < COMPRESS_THRESHOLD) return json;
46
+
47
+ return new Promise((resolve) => {
48
+ zlib.brotliCompress(Buffer.from(json), BROTLI_OPTIONS, (error, compressed) => {
49
+ if (error) resolve(json);
50
+ else resolve(pickPayload(json, compressed));
51
+ });
52
+ });
53
+ }
54
+
55
+ /**
56
+ * @param {Buffer | string} raw
57
+ * @returns {unknown}
58
+ */
59
+ export function decodeCacheValue(raw) {
60
+ if (
61
+ Buffer.isBuffer(raw) &&
62
+ raw.length > CACHE_MAGIC.length &&
63
+ raw.subarray(0, CACHE_MAGIC.length).equals(CACHE_MAGIC)
64
+ ) {
65
+ return JSON.parse(zlib.brotliDecompressSync(raw.subarray(CACHE_MAGIC.length)).toString("utf8"));
66
+ }
67
+
68
+ const text = Buffer.isBuffer(raw) ? raw.toString("utf8") : raw;
69
+ return JSON.parse(text);
70
+ }
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Tarayıcıya `max-age=0`, edge'e `CDN-Cache-Control`.
3
+ *
4
+ * `s-maxage` yazılmaz: Cloudflare `max-age=0` ile birlikte görünce nesneyi
5
+ * EXPIRED sayar. `must-revalidate`, `proxy-revalidate` ve `no-cache` aynı
6
+ * yanıtta stale penceresini keser; burada üretilmez. `s-maxage`'e dönen bir
7
+ * anahtar da yok — o mod aynı EXPIRED sonucunu geri getirir.
8
+ *
9
+ * Süreç içi HTML cache (`X-JSkelet-Cache: STALE`) ayrı katmandır; bu modül
10
+ * ona dokunmaz. Görsel optimizer kendi `max-age` + `stale-while-revalidate`
11
+ * yolunu kullanır.
12
+ */
13
+
14
+ /** Tarayıcı kopyası tutulmaz; taze pencere edge başlığındadır. */
15
+ const BROWSER_CACHE = "public, max-age=0";
16
+
17
+ /**
18
+ * @param {number} maxAge Edge'in taze penceresi (saniye). HTML'de route TTL.
19
+ * @param {number} staleWhileRevalidate Taze pencere bitince eski kopyanın
20
+ * sunulacağı süre. 0 ise direktif basılmaz.
21
+ * @returns {{ cacheControl: string, cdnCacheControl: string }}
22
+ */
23
+ export function edgeCacheControl(maxAge, staleWhileRevalidate) {
24
+ /** @type {string[]} */
25
+ const directives = [`max-age=${maxAge}`];
26
+ if (staleWhileRevalidate > 0) {
27
+ directives.push(`stale-while-revalidate=${staleWhileRevalidate}`);
28
+ }
29
+
30
+ return {
31
+ cacheControl: BROWSER_CACHE,
32
+ cdnCacheControl: directives.join(", "),
33
+ };
34
+ }
35
+
36
+ /**
37
+ * @param {import('express').Response} res
38
+ * @param {number} maxAge
39
+ * @param {number} staleWhileRevalidate
40
+ */
41
+ export function setEdgeCacheHeaders(res, maxAge, staleWhileRevalidate) {
42
+ const headers = edgeCacheControl(maxAge, staleWhileRevalidate);
43
+ res.setHeader("Cache-Control", headers.cacheControl);
44
+ res.setHeader("CDN-Cache-Control", headers.cdnCacheControl);
45
+ }
@@ -20,12 +20,13 @@
20
20
  * `cache.redis` açıkken bu önbellek ikinci bir kademeye (L2) yaslanır. Redis'e
21
21
  * en uygun katman burası: JSON küçük, sıkıştırılmış varyant sorunu yok ve
22
22
  * kazanç doğrudan API kotasına yazılıyor — bir node'un çektiği veri hepsine
23
- * yeter. Redis kapalı ya da erişilemez olduğunda bu modül birebir eskisi gibi
24
- * çalışır.
23
+ * yeter. Redis yoksa aynı kayıt diske yazılır; süreç yeniden açılınca upstream
24
+ * yeniden çağrılmaz.
25
25
  */
26
26
  import { getConfig } from "../config/index.js";
27
- import { DEFAULT_DATA_CACHE } from "../config/defaults.js";
27
+ import { DATA_CACHE_BYTE_BUDGET, DEFAULT_DATA_CACHE } from "../config/defaults.js";
28
28
  import { recordDependency } from "./cache-deps.js";
29
+ import { diskDrop, diskDropMatching, diskGetJson, diskSetJson, diskShares } from "./disk-cache.js";
29
30
  import { invalidateHtmlByDependency } from "./html-cache.js";
30
31
  import {
31
32
  cacheKey,
@@ -38,12 +39,24 @@ import {
38
39
  } from "./redis.js";
39
40
 
40
41
  /**
41
- * @typedef {{ value: unknown, expiresAt: number, staleUntil: number }} DataEntry
42
+ * @typedef {{ value: unknown, expiresAt: number, staleUntil: number, bytes: number }} DataEntry
42
43
  */
43
44
 
44
45
  /** @type {Map<string, DataEntry>} */
45
46
  const store = new Map();
46
47
 
48
+ /**
49
+ * `store` içindeki JSON gövdelerin toplamı. Tam tarama yapmamak için tutulur.
50
+ * Her silme `forget` üzerinden geçer; LRU sırası değişimi sayacı oynatmaz.
51
+ */
52
+ let storedBytes = 0;
53
+
54
+ /**
55
+ * Testler tahliyeyi küçük bir bütçeyle doğrular. `null` → üretim tavanı.
56
+ * @type {number | null}
57
+ */
58
+ let byteBudgetOverride = null;
59
+
47
60
  /** @type {Map<string, Promise<unknown>>} */
48
61
  const inflight = new Map();
49
62
 
@@ -103,27 +116,85 @@ function read(key) {
103
116
 
104
117
  const now = Date.now();
105
118
  if (now >= entry.staleUntil) {
106
- store.delete(key);
119
+ forget(key);
107
120
  return null;
108
121
  }
109
122
 
110
- // LRU: erişilen girdiyi sona taşı.
123
+ // LRU: erişilen girdiyi sona taşı. Bayt sayacı değişmez.
111
124
  store.delete(key);
112
125
  store.set(key, entry);
113
126
 
114
127
  return { value: entry.value, stale: now >= entry.expiresAt };
115
128
  }
116
129
 
117
- /** Girdi sınırını aşan en eski kayıtları düşürür. */
130
+ /**
131
+ * @param {unknown} value
132
+ * @returns {number}
133
+ */
134
+ function valueBytes(value) {
135
+ try {
136
+ const json = JSON.stringify(value);
137
+ if (typeof json !== "string") return 0;
138
+ return Buffer.byteLength(json);
139
+ } catch {
140
+ // Serileşmeyen değer yine saklanır; ölçülemediği için küçük bir pay.
141
+ return 1024;
142
+ }
143
+ }
144
+
145
+ /** @returns {number} */
146
+ function activeByteBudget() {
147
+ return byteBudgetOverride ?? DATA_CACHE_BYTE_BUDGET;
148
+ }
149
+
150
+ /**
151
+ * Store'dan silmenin tek yolu. Bayt sayacı buraya bağlı.
152
+ *
153
+ * @param {string} key
154
+ * @returns {boolean}
155
+ */
156
+ function forget(key) {
157
+ const entry = store.get(key);
158
+ if (!entry) return false;
159
+ storedBytes = Math.max(0, storedBytes - (entry.bytes || 0));
160
+ store.delete(key);
161
+ return true;
162
+ }
163
+
164
+ /**
165
+ * Sayı tavanı veya bayt bütçesi aşılınca en eski girdiden düşer.
166
+ * @returns {void}
167
+ */
118
168
  function evict() {
119
169
  const { maxEntries } = settings();
120
- while (store.size > maxEntries) {
170
+ const budget = activeByteBudget();
171
+
172
+ while (store.size > 0 && (store.size > maxEntries || storedBytes > budget)) {
121
173
  const oldest = store.keys().next().value;
122
174
  if (oldest === undefined) break;
123
- store.delete(oldest);
175
+ if (!forget(oldest)) break;
124
176
  }
125
177
  }
126
178
 
179
+ /**
180
+ * L1'e yazar. Bütçeden büyük tek değer saklanmaz.
181
+ *
182
+ * @param {string} key
183
+ * @param {DataEntry} entry
184
+ * @returns {boolean}
185
+ */
186
+ function install(key, entry) {
187
+ const bytes = entry.bytes || valueBytes(entry.value);
188
+ entry.bytes = bytes;
189
+ forget(key);
190
+ if (bytes > activeByteBudget()) return false;
191
+
192
+ store.set(key, entry);
193
+ storedBytes += bytes;
194
+ evict();
195
+ return store.has(key);
196
+ }
197
+
127
198
  /**
128
199
  * @param {string} key
129
200
  * @param {unknown} value
@@ -139,17 +210,20 @@ function write(key, value, ttlSeconds, staleFactor) {
139
210
  value,
140
211
  expiresAt: now + ttl,
141
212
  staleUntil: now + ttl + ttl * staleFactor,
213
+ bytes: valueBytes(value),
142
214
  };
143
215
 
144
- store.set(key, entry);
216
+ // Bütçeye sığmayan değer Redis'e de yazılmaz: bir sonraki istek onu geri
217
+ // alıp yine reddeder. Çağıran sonuç yine `producer`'ın döndürdüğüdür.
218
+ if (!install(key, entry)) return;
145
219
 
146
220
  // Redis kopyası ateşle-unut: çağıran taraf beklemez. Anahtarın Redis ömrü
147
221
  // bayat penceresinin sonuna kadar, çünkü bayat veri de işe yarıyor.
148
222
  if (redisShares("data")) {
149
223
  redisSetJson(cacheKey("data", key), entry, entry.staleUntil - now);
224
+ } else if (diskShares("data")) {
225
+ diskSetJson("data", key, entry);
150
226
  }
151
-
152
- evict();
153
227
  }
154
228
 
155
229
  /**
@@ -161,8 +235,8 @@ function write(key, value, ttlSeconds, staleFactor) {
161
235
  * @param {DataEntry} entry
162
236
  */
163
237
  function promote(key, entry) {
164
- store.set(key, entry);
165
- evict();
238
+ if (!entry.bytes) entry.bytes = valueBytes(entry.value);
239
+ install(key, entry);
166
240
  }
167
241
 
168
242
  /**
@@ -176,11 +250,16 @@ function promote(key, entry) {
176
250
  * @returns {Promise<DataEntry | null>}
177
251
  */
178
252
  async function readShared(key) {
179
- if (!redisShares("data")) return null;
253
+ const onRedis = redisShares("data");
254
+ const onDisk = diskShares("data");
255
+ if (!onRedis && !onDisk) return null;
180
256
 
181
- const entry = await redisGetJson(cacheKey("data", key));
257
+ const entry = onRedis ? await redisGetJson(cacheKey("data", key)) : await diskGetJson("data", key);
182
258
  if (!entry || typeof entry.expiresAt !== "number") return null;
183
- if (Date.now() >= entry.expiresAt) return null;
259
+ if (Date.now() >= entry.expiresAt) {
260
+ if (onDisk) diskDrop("data", [key]);
261
+ return null;
262
+ }
184
263
 
185
264
  return /** @type {DataEntry} */ (entry);
186
265
  }
@@ -340,12 +419,9 @@ export function dataCache(fn, options) {
340
419
  export function clearDataCache(prefix) {
341
420
  const removed = clearLocal(prefix);
342
421
 
343
- if (redisShares("data")) {
344
- void redisDropMatching(
345
- "data",
346
- prefix === undefined ? undefined : (key) => key.startsWith(prefix),
347
- );
348
- }
422
+ const match = prefix === undefined ? undefined : (key) => key.startsWith(prefix);
423
+ if (redisShares("data")) void redisDropMatching("data", match);
424
+ else if (diskShares("data")) void diskDropMatching("data", match);
349
425
 
350
426
  // Yayın yerel silmeden **sonra** yapılır; diğer node'lar kendi anahtarlarını
351
427
  // kendileri tarar, çünkü hangi anahtarın nerede sıcak olduğu node'a bağlı.
@@ -368,10 +444,11 @@ function clearLocal(prefix) {
368
444
  if (prefix === undefined) {
369
445
  removed.push(...store.keys());
370
446
  store.clear();
447
+ storedBytes = 0;
371
448
  } else {
372
- for (const key of store.keys()) {
449
+ for (const key of [...store.keys()]) {
373
450
  if (key.startsWith(prefix)) {
374
- store.delete(key);
451
+ forget(key);
375
452
  removed.push(key);
376
453
  }
377
454
  }
@@ -386,7 +463,7 @@ function clearLocal(prefix) {
386
463
  onCacheEvent((event) => {
387
464
  if (event.type === "data:drop") {
388
465
  if (typeof event.key !== "string") return;
389
- store.delete(event.key);
466
+ forget(event.key);
390
467
  invalidateHtmlByDependency([event.key]);
391
468
  return;
392
469
  }
@@ -406,7 +483,7 @@ onCacheEvent((event) => {
406
483
  * @returns {boolean} Girdi var mıydı.
407
484
  */
408
485
  export function dropDataCacheKey(key) {
409
- const existed = store.delete(key);
486
+ const existed = forget(key);
410
487
 
411
488
  // Silme, girdi bu node'da olmasa da yayılır: anahtar başka bir node'da ya da
412
489
  // yalnızca Redis'te sıcak olabilir.
@@ -414,6 +491,8 @@ export function dropDataCacheKey(key) {
414
491
 
415
492
  if (redisShares("data")) {
416
493
  void redisDropMatching("data", (candidate) => candidate === key);
494
+ } else if (diskShares("data")) {
495
+ diskDrop("data", [key]);
417
496
  }
418
497
 
419
498
  publishCacheEvent({ type: "data:drop", key });
@@ -426,6 +505,18 @@ export function getDataCacheSize() {
426
505
  return store.size;
427
506
  }
428
507
 
508
+ /**
509
+ * Bellek freninin bayt tavanını geçici olarak değiştirir. Testler LRU
510
+ * tahliyesini küçük bir değerle doğrular; `null` üretim tavanına döner.
511
+ *
512
+ * @param {number | null} bytes
513
+ * @returns {void}
514
+ */
515
+ export function setDataCacheByteBudget(bytes) {
516
+ byteBudgetOverride = bytes == null ? null : bytes;
517
+ evict();
518
+ }
519
+
429
520
  /**
430
521
  * Süreç başından beri biriken sayaçlar. `produced` kotaya yazılan tek sayıdır:
431
522
  * geri kalan her şey upstream'e hiç gitmemiş bir okuma.
@@ -0,0 +1,233 @@
1
+ /**
2
+ * Redis yokken L2: aynı brotli gövde `.jskelet/cache/<buildId>/` altına yazılır.
3
+ *
4
+ * Tek makinenin yeniden açılışını karşılar. Birden fazla instance aynı
5
+ * dizini paylaşmaz — o iş Redis'te kalır. Redis o türü paylaşıyorsa disk
6
+ * devreye girmez; iki kopya birbirini ezmesin.
7
+ *
8
+ * Dosya adı anahtarın sha256'sı. Başta anahtarın kendisi durur ki desenle
9
+ * silmek için gövdeyi çözmek gerekmesin. Süre Redis'teki `PX` yerine
10
+ * girdinin `expiresAt` alanındadır; süresi dolmuş dosya okununca silinir.
11
+ */
12
+ import crypto from "node:crypto";
13
+ import fs from "node:fs/promises";
14
+ import path from "node:path";
15
+ import { getConfig } from "../config/index.js";
16
+ import { getBuildId } from "./assets.js";
17
+ import { decodeCacheValue, encodeCacheValue } from "./cache-blob.js";
18
+ import { redisShares } from "./redis.js";
19
+
20
+ /** @type {string | null} `null` → config'den çöz. Testler mutlak yol verir. */
21
+ let testRoot = null;
22
+
23
+ let pruned = false;
24
+
25
+ /**
26
+ * @param {string | null} root Mutlak dizin, ya da config yoluna dönmek için `null`.
27
+ */
28
+ export function setDiskCacheRootForTests(root) {
29
+ testRoot = root;
30
+ pruned = false;
31
+ }
32
+
33
+ /**
34
+ * @returns {string | null}
35
+ */
36
+ function directory() {
37
+ if (typeof testRoot === "string") return testRoot;
38
+
39
+ try {
40
+ return path.join(getConfig().dirs.generated, "cache", getBuildId());
41
+ } catch {
42
+ // Birim testleri config yüklemez. Disk yazısı o zaman da kapalı kalır.
43
+ return null;
44
+ }
45
+ }
46
+
47
+ /**
48
+ * @param {"html" | "data"} kind
49
+ * @returns {boolean}
50
+ */
51
+ export function diskShares(kind) {
52
+ if (redisShares(kind)) return false;
53
+ return directory() !== null;
54
+ }
55
+
56
+ /**
57
+ * @param {string} key
58
+ * @returns {string}
59
+ */
60
+ function fileName(key) {
61
+ return crypto.createHash("sha256").update(key).digest("hex");
62
+ }
63
+
64
+ /**
65
+ * @param {"html" | "data"} kind
66
+ * @param {string} key
67
+ * @returns {string | null}
68
+ */
69
+ function filePath(kind, key) {
70
+ const root = directory();
71
+ if (!root) return null;
72
+ return path.join(root, kind, fileName(key));
73
+ }
74
+
75
+ /**
76
+ * @param {string} key
77
+ * @param {string | Buffer} payload
78
+ * @returns {Buffer}
79
+ */
80
+ function frame(key, payload) {
81
+ const keyBuf = Buffer.from(key);
82
+ const header = Buffer.alloc(4);
83
+ header.writeUInt32BE(keyBuf.length);
84
+ const body = Buffer.isBuffer(payload) ? payload : Buffer.from(payload);
85
+ return Buffer.concat([header, keyBuf, body]);
86
+ }
87
+
88
+ /**
89
+ * @param {Buffer} raw
90
+ * @returns {{ key: string, body: Buffer } | null}
91
+ */
92
+ function unframe(raw) {
93
+ if (raw.length < 4) return null;
94
+ const keyLen = raw.readUInt32BE(0);
95
+ if (keyLen < 1 || keyLen > 65536 || raw.length < 4 + keyLen) return null;
96
+ return {
97
+ key: raw.subarray(4, 4 + keyLen).toString("utf8"),
98
+ body: raw.subarray(4 + keyLen),
99
+ };
100
+ }
101
+
102
+ /**
103
+ * Eski build kimliğinin dizinini siler. Redis'te o anahtarlar TTL ile ölür;
104
+ * dosyada karşılığı bu. Dev'de kimlik `dev` kaldığı için prune bir şey silmez.
105
+ *
106
+ * @param {string} dir
107
+ */
108
+ function pruneOldBuilds(dir) {
109
+ if (pruned || testRoot) return;
110
+ pruned = true;
111
+
112
+ const parent = path.dirname(dir);
113
+ const current = path.basename(dir);
114
+
115
+ fs.readdir(parent, { withFileTypes: true })
116
+ .then((entries) =>
117
+ Promise.all(
118
+ entries
119
+ .filter((entry) => entry.isDirectory() && entry.name !== current)
120
+ .map((entry) => fs.rm(path.join(parent, entry.name), { recursive: true, force: true })),
121
+ ),
122
+ )
123
+ .catch(() => {
124
+ // Dizin henüz yoksa ya da silinemiyorsa L1 çalışmaya devam eder.
125
+ });
126
+ }
127
+
128
+ /**
129
+ * @param {"html" | "data"} kind
130
+ * @param {string} key
131
+ * @returns {Promise<unknown | null>}
132
+ */
133
+ export async function diskGetJson(kind, key) {
134
+ const file = filePath(kind, key);
135
+ if (!file) return null;
136
+
137
+ try {
138
+ const framed = unframe(await fs.readFile(file));
139
+ if (!framed) return null;
140
+ return decodeCacheValue(framed.body);
141
+ } catch {
142
+ return null;
143
+ }
144
+ }
145
+
146
+ /**
147
+ * Ateşle-unut. Dönüş değeri testler bekleyebilsin diye durur; istek yolu
148
+ * beklemez.
149
+ *
150
+ * @param {"html" | "data"} kind
151
+ * @param {string} key
152
+ * @param {unknown} value
153
+ * @returns {Promise<void>}
154
+ */
155
+ export function diskSetJson(kind, key, value) {
156
+ const root = directory();
157
+ const file = filePath(kind, key);
158
+ if (!root || !file) return Promise.resolve();
159
+
160
+ pruneOldBuilds(root);
161
+
162
+ const encoded = encodeCacheValue(value);
163
+ if (encoded === null) return Promise.resolve();
164
+
165
+ const write = (/** @type {string | Buffer} */ payload) => {
166
+ const tmp = `${file}.${process.pid}.tmp`;
167
+ return fs
168
+ .mkdir(path.dirname(file), { recursive: true })
169
+ .then(() => fs.writeFile(tmp, frame(key, payload)))
170
+ .then(() => fs.rename(tmp, file))
171
+ .catch((error) => {
172
+ console.warn(
173
+ "[disk-cache] write failed",
174
+ error instanceof Error ? error.message : error,
175
+ );
176
+ });
177
+ };
178
+
179
+ if (typeof encoded === "string" || Buffer.isBuffer(encoded)) return write(encoded);
180
+ return encoded.then(write);
181
+ }
182
+
183
+ /**
184
+ * @param {"html" | "data"} kind
185
+ * @param {string[]} keys
186
+ */
187
+ export function diskDrop(kind, keys) {
188
+ if (!directory() || !keys.length) return;
189
+
190
+ for (const key of keys) {
191
+ const file = filePath(kind, key);
192
+ if (file) fs.rm(file, { force: true }).catch(() => {});
193
+ }
194
+ }
195
+
196
+ /**
197
+ * @param {"html" | "data"} kind
198
+ * @param {(key: string) => boolean} [match] Verilmezse türün tamamı silinir.
199
+ * @returns {Promise<number>}
200
+ */
201
+ export async function diskDropMatching(kind, match) {
202
+ const root = directory();
203
+ if (!root) return 0;
204
+
205
+ const folder = path.join(root, kind);
206
+ /** @type {string[]} */
207
+ let names;
208
+ try {
209
+ names = await fs.readdir(folder);
210
+ } catch {
211
+ return 0;
212
+ }
213
+
214
+ let dropped = 0;
215
+
216
+ await Promise.all(
217
+ names.map(async (name) => {
218
+ const file = path.join(folder, name);
219
+ try {
220
+ if (match) {
221
+ const framed = unframe(await fs.readFile(file));
222
+ if (!framed || !match(framed.key)) return;
223
+ }
224
+ await fs.rm(file, { force: true });
225
+ dropped += 1;
226
+ } catch {
227
+ // Yarışta silinmiş dosya sorun değil.
228
+ }
229
+ }),
230
+ );
231
+
232
+ return dropped;
233
+ }