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.
- package/CHANGELOG.md +32 -0
- package/README.md +2 -0
- package/docs/02-mimari.md +1 -0
- package/docs/04-render-ve-sablonlar.md +9 -3
- package/docs/06-cache.md +53 -9
- package/docs/07-yapilandirma.md +1208 -1191
- package/docs/08-build.md +2 -1
- package/docs/10-dagitim.md +16 -6
- package/docs/12-panel-ve-oturum.md +2 -1
- package/docs/en/02-architecture.md +2 -1
- package/docs/en/04-rendering.md +9 -3
- package/docs/en/06-caching.md +54 -9
- package/docs/en/07-configuration.md +24 -9
- package/docs/en/08-build.md +2 -1
- package/docs/en/10-deployment.md +17 -6
- package/docs/en/12-dashboards-and-sessions.md +2 -1
- package/package.json +1 -1
- package/src/config/defaults.js +541 -518
- package/src/config/index.js +1500 -1456
- package/src/init.mjs +2 -0
- package/src/server/cache-blob.js +70 -0
- package/src/server/cache-control.js +45 -0
- package/src/server/data-cache.js +118 -27
- package/src/server/disk-cache.js +233 -0
- package/src/server/html-cache.js +90 -16
- package/src/server/image-optimizer.js +95 -2
- package/src/server/logs/file-sink.js +159 -32
- package/src/server/logs/pipeline.js +10 -3
- package/src/server/middleware/static-precompressed.js +31 -10
- package/src/server/og-image.js +17 -4
- package/src/server/prewarm.js +25 -1
- package/src/server/redis.js +31 -12
- package/src/server/render.js +910 -910
- package/types/config/defaults.d.ts +21 -1
- package/types/config/index.d.ts +14 -0
- package/types/server/cache-blob.d.ts +13 -0
- package/types/server/cache-control.d.ts +28 -0
- package/types/server/data-cache.d.ts +9 -0
- package/types/server/disk-cache.d.ts +36 -0
- package/types/server/html-cache.d.ts +26 -3
- package/types/server/logs/file-sink.d.ts +16 -5
- package/types/server/og-image.d.ts +5 -0
- package/types/server/redis.d.ts +2 -1
package/src/init.mjs
CHANGED
|
@@ -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
|
+
}
|
package/src/server/data-cache.js
CHANGED
|
@@ -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
|
|
24
|
-
*
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
165
|
-
|
|
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
|
-
|
|
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)
|
|
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
|
-
|
|
344
|
-
|
|
345
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
+
}
|