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/server/html-cache.js
CHANGED
|
@@ -26,8 +26,8 @@
|
|
|
26
26
|
* (L1) **birincil kalır**: `read()` senkron, sıkıştırılmış gövdeler girdiyle
|
|
27
27
|
* birlikte ve tutarlılık makinesi (`tokens`, `purgedDeps`) tek proseste. Redis
|
|
28
28
|
* yalnızca L1'de bulunmayan bir yol için render'ı atlatır ve invalidation'ı
|
|
29
|
-
* diğer node'lara duyurur. Redis
|
|
30
|
-
*
|
|
29
|
+
* diğer node'lara duyurur. Redis yoksa aynı kayıt `.jskelet/cache/<buildId>/`
|
|
30
|
+
* altına yazılır; bu tek makinenin yeniden açılışını karşılar, kümeyi değil.
|
|
31
31
|
*/
|
|
32
32
|
|
|
33
33
|
import { getConfig } from "../config/index.js";
|
|
@@ -38,6 +38,7 @@ import {
|
|
|
38
38
|
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
|
+
import { diskDrop, diskDropMatching, diskGetJson, diskSetJson, diskShares } from "./disk-cache.js";
|
|
41
42
|
import {
|
|
42
43
|
cacheKey,
|
|
43
44
|
onCacheEvent,
|
|
@@ -118,6 +119,13 @@ const EARLY_REFRESH_MIN_MS = 250;
|
|
|
118
119
|
/** Trafiksiz girdileri erken pencerede soft-bayatlatma aralığı. */
|
|
119
120
|
const EARLY_SWEEP_INTERVAL_MS = 1000;
|
|
120
121
|
|
|
122
|
+
/**
|
|
123
|
+
* Bir sweep turunda soft-bayatlatılan girdi tavanı. Aynı TTL'i paylaşan
|
|
124
|
+
* yüzlerce sayfa tek saniyede kuyruğa girip render dalgası yaratmasın;
|
|
125
|
+
* kalanlar sonraki turlara kalır. Öncelik `expiresAt`'i en yakın olan.
|
|
126
|
+
*/
|
|
127
|
+
const EARLY_SWEEP_MARK_BUDGET = 4;
|
|
128
|
+
|
|
121
129
|
/** @type {Map<string, HtmlEntry>} */
|
|
122
130
|
const store = new Map();
|
|
123
131
|
|
|
@@ -354,7 +362,9 @@ function read(key) {
|
|
|
354
362
|
* @param {HtmlEntry} entry
|
|
355
363
|
*/
|
|
356
364
|
function share(key, entry) {
|
|
357
|
-
|
|
365
|
+
const onRedis = redisShares("html");
|
|
366
|
+
const onDisk = diskShares("html");
|
|
367
|
+
if (!onRedis && !onDisk) return;
|
|
358
368
|
|
|
359
369
|
const ttlMs = entry.staleUntil - Date.now();
|
|
360
370
|
if (ttlMs <= 0) return;
|
|
@@ -370,7 +380,7 @@ function share(key, entry) {
|
|
|
370
380
|
deps: [...entry.deps],
|
|
371
381
|
};
|
|
372
382
|
|
|
373
|
-
if (redisSharesEncoded() && entry.encoded.size) {
|
|
383
|
+
if (onRedis && redisSharesEncoded() && entry.encoded.size) {
|
|
374
384
|
/** @type {Record<string, string>} */
|
|
375
385
|
const encoded = {};
|
|
376
386
|
for (const [encoding, buffer] of entry.encoded) {
|
|
@@ -380,7 +390,8 @@ function share(key, entry) {
|
|
|
380
390
|
entry.sharedEncodings = entry.encoded.size;
|
|
381
391
|
}
|
|
382
392
|
|
|
383
|
-
redisSetJson(cacheKey("html", key), payload, ttlMs);
|
|
393
|
+
if (onRedis) redisSetJson(cacheKey("html", key), payload, ttlMs);
|
|
394
|
+
else diskSetJson("html", key, payload);
|
|
384
395
|
}
|
|
385
396
|
|
|
386
397
|
/**
|
|
@@ -394,11 +405,17 @@ function share(key, entry) {
|
|
|
394
405
|
* @returns {Promise<HtmlEntry | null>}
|
|
395
406
|
*/
|
|
396
407
|
async function readShared(key) {
|
|
397
|
-
|
|
408
|
+
const onRedis = redisShares("html");
|
|
409
|
+
const onDisk = diskShares("html");
|
|
410
|
+
if (!onRedis && !onDisk) return null;
|
|
398
411
|
|
|
399
|
-
const payload =
|
|
412
|
+
const payload = onRedis
|
|
413
|
+
? await redisGetJson(cacheKey("html", key))
|
|
414
|
+
: await diskGetJson("html", key);
|
|
400
415
|
if (!payload || typeof payload.html !== "string") return null;
|
|
401
416
|
if (typeof payload.expiresAt !== "number" || Date.now() >= payload.expiresAt) {
|
|
417
|
+
// Dosyanın TTL'i yok; süresi dolmuş kopya okununca silinir.
|
|
418
|
+
if (onDisk) diskDrop("html", [key]);
|
|
402
419
|
return null;
|
|
403
420
|
}
|
|
404
421
|
|
|
@@ -418,6 +435,14 @@ async function readShared(key) {
|
|
|
418
435
|
encoded.set(encoding, Buffer.from(base64, "base64"));
|
|
419
436
|
}
|
|
420
437
|
}
|
|
438
|
+
// Eski bir replica iki kodlama yazmış olabilir. L1 tek kopya tutar;
|
|
439
|
+
// brotli varsa o kalır.
|
|
440
|
+
if (encoded.size > 1) {
|
|
441
|
+
const keep = encoded.has("br") ? "br" : encoded.keys().next().value;
|
|
442
|
+
const buffer = keep ? encoded.get(keep) : undefined;
|
|
443
|
+
encoded.clear();
|
|
444
|
+
if (keep && buffer) encoded.set(keep, buffer);
|
|
445
|
+
}
|
|
421
446
|
}
|
|
422
447
|
|
|
423
448
|
return {
|
|
@@ -665,6 +690,7 @@ export function clearHtmlCache() {
|
|
|
665
690
|
clearLocal();
|
|
666
691
|
|
|
667
692
|
if (redisShares("html")) void redisDropMatching("html");
|
|
693
|
+
else if (diskShares("html")) void diskDropMatching("html");
|
|
668
694
|
publishCacheEvent({ type: "html:clear" });
|
|
669
695
|
}
|
|
670
696
|
|
|
@@ -767,12 +793,12 @@ export function invalidateHtmlCache(target, options = {}) {
|
|
|
767
793
|
// webhook binlerce anahtarı birden düşürüyor. Silmenin bedeli, o yolu hiç
|
|
768
794
|
// görmemiş bir node'un bir kez render etmesi; L1'i sıcak olan node'lar eski
|
|
769
795
|
// HTML'i bayat pencerede servis etmeye devam ediyor.
|
|
770
|
-
if (redisShares("html")) {
|
|
796
|
+
if (redisShares("html") || diskShares("html")) {
|
|
771
797
|
const matchers = compileMatchers(targets);
|
|
772
798
|
if (matchers.length) {
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
);
|
|
799
|
+
const match = (key) => matchers.some((matcher) => matcher(pathOf(key)));
|
|
800
|
+
if (redisShares("html")) void redisDropMatching("html", match);
|
|
801
|
+
else void diskDropMatching("html", match);
|
|
776
802
|
}
|
|
777
803
|
}
|
|
778
804
|
|
|
@@ -926,6 +952,8 @@ export function invalidateHtmlByDependency(dataKeys) {
|
|
|
926
952
|
// kopyası TTL'ini bekler.
|
|
927
953
|
if (keys.size && redisShares("html")) {
|
|
928
954
|
redisDrop([...keys].map((key) => cacheKey("html", key)));
|
|
955
|
+
} else if (keys.size && diskShares("html")) {
|
|
956
|
+
diskDrop("html", [...keys]);
|
|
929
957
|
}
|
|
930
958
|
|
|
931
959
|
return keys.size;
|
|
@@ -946,6 +974,7 @@ export function dropHtmlCacheKey(key) {
|
|
|
946
974
|
const existed = dropLocalKey(key);
|
|
947
975
|
|
|
948
976
|
if (redisShares("html")) redisDrop([cacheKey("html", key)]);
|
|
977
|
+
else if (diskShares("html")) diskDrop("html", [key]);
|
|
949
978
|
publishCacheEvent({ type: "html:drop", key });
|
|
950
979
|
|
|
951
980
|
return existed;
|
|
@@ -997,19 +1026,19 @@ function pathWithQuery(key) {
|
|
|
997
1026
|
* açılmaz.
|
|
998
1027
|
*
|
|
999
1028
|
* @param {string} [onlyHost]
|
|
1000
|
-
* @returns {{ path: string, host: string }[]}
|
|
1029
|
+
* @returns {{ key: string, path: string, host: string }[]}
|
|
1001
1030
|
*/
|
|
1002
1031
|
export function takeInvalidatedTargets(onlyHost) {
|
|
1003
1032
|
if (!invalidated.size) return [];
|
|
1004
1033
|
|
|
1005
|
-
/** @type {{ path: string, host: string }[]} */
|
|
1034
|
+
/** @type {{ key: string, path: string, host: string }[]} */
|
|
1006
1035
|
const taken = [];
|
|
1007
1036
|
|
|
1008
1037
|
for (const key of [...invalidated]) {
|
|
1009
1038
|
const host = hostOfCacheKey(key);
|
|
1010
1039
|
if (onlyHost && host && host !== onlyHost) continue;
|
|
1011
1040
|
invalidated.delete(key);
|
|
1012
|
-
taken.push({ path: pathWithQuery(key), host });
|
|
1041
|
+
taken.push({ key, path: pathWithQuery(key), host });
|
|
1013
1042
|
}
|
|
1014
1043
|
|
|
1015
1044
|
return taken;
|
|
@@ -1082,25 +1111,70 @@ export function isHtmlCacheFresh(pathname, req) {
|
|
|
1082
1111
|
return false;
|
|
1083
1112
|
}
|
|
1084
1113
|
|
|
1114
|
+
/**
|
|
1115
|
+
* Bu anahtarın girdisi hâlâ taze mi? Süre dolumu ısıtması anahtarı bildiği
|
|
1116
|
+
* için yol taraması yapmaz. Soft-bayat (`expiresAt === 0`) taze sayılmaz:
|
|
1117
|
+
* ziyaretçi arada yenilediyse yeni `expiresAt` taze döner ve HTTP atlanır.
|
|
1118
|
+
*
|
|
1119
|
+
* @param {string} key
|
|
1120
|
+
* @returns {boolean}
|
|
1121
|
+
*/
|
|
1122
|
+
export function isHtmlCacheKeyFresh(key) {
|
|
1123
|
+
const entry = store.get(key);
|
|
1124
|
+
if (!entry) return false;
|
|
1125
|
+
return Date.now() < entry.expiresAt;
|
|
1126
|
+
}
|
|
1127
|
+
|
|
1128
|
+
/**
|
|
1129
|
+
* 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.
|
|
1131
|
+
*
|
|
1132
|
+
* @param {Map<string, Buffer>} encoded
|
|
1133
|
+
* @param {string} encoding
|
|
1134
|
+
* @param {Buffer} buffer
|
|
1135
|
+
* @returns {void}
|
|
1136
|
+
*/
|
|
1137
|
+
export function rememberHtmlEncoding(encoded, encoding, buffer) {
|
|
1138
|
+
for (const key of [...encoded.keys()]) {
|
|
1139
|
+
if (key !== encoding) encoded.delete(key);
|
|
1140
|
+
}
|
|
1141
|
+
encoded.set(encoding, buffer);
|
|
1142
|
+
noteHtmlCacheGrowth();
|
|
1143
|
+
}
|
|
1144
|
+
|
|
1085
1145
|
/**
|
|
1086
1146
|
* Erken tazeleme penceresine girmiş (veya TTL'i dolmuş) trafiksiz girdileri
|
|
1087
1147
|
* soft-bayatlatır ve ısıtma kuyruğuna alır. HTTP ısıtması producer'sız
|
|
1088
1148
|
* çalıştığı için soft-bayat şart: taze HIT yenileme tetiklemez.
|
|
1089
1149
|
*
|
|
1150
|
+
* Tur başına en fazla `EARLY_SWEEP_MARK_BUDGET` girdi. Önce süresi en yakın
|
|
1151
|
+
* dolacak olan; kota dolunca kalanlar bir sonraki tura kalır.
|
|
1152
|
+
*
|
|
1090
1153
|
* @returns {number} İşaretlenen girdi sayısı.
|
|
1091
1154
|
*/
|
|
1092
1155
|
export function sweepEarlyExpiry() {
|
|
1093
1156
|
const now = Date.now();
|
|
1094
|
-
|
|
1157
|
+
/** @type {{ key: string, expiresAt: number }[]} */
|
|
1158
|
+
const due = [];
|
|
1095
1159
|
|
|
1096
1160
|
for (const [key, entry] of store) {
|
|
1097
1161
|
if (inflight.has(key)) continue;
|
|
1098
1162
|
// Zaten soft-bayat / kuyrukta — her saniye yeniden ekleme.
|
|
1099
1163
|
if (entry.expiresAt === 0) continue;
|
|
1100
1164
|
if (now < entry.expiresAt && !isEarly(entry, now)) continue;
|
|
1165
|
+
due.push({ key, expiresAt: entry.expiresAt });
|
|
1166
|
+
}
|
|
1167
|
+
|
|
1168
|
+
due.sort((a, b) => a.expiresAt - b.expiresAt);
|
|
1169
|
+
|
|
1170
|
+
let marked = 0;
|
|
1171
|
+
for (const item of due) {
|
|
1172
|
+
if (marked >= EARLY_SWEEP_MARK_BUDGET) break;
|
|
1173
|
+
const entry = store.get(item.key);
|
|
1174
|
+
if (!entry || inflight.has(item.key) || entry.expiresAt === 0) continue;
|
|
1101
1175
|
|
|
1102
1176
|
entry.expiresAt = 0;
|
|
1103
|
-
if (invalidated.size < MAX_INVALIDATED) invalidated.add(key);
|
|
1177
|
+
if (invalidated.size < MAX_INVALIDATED) invalidated.add(item.key);
|
|
1104
1178
|
marked += 1;
|
|
1105
1179
|
}
|
|
1106
1180
|
|
|
@@ -19,6 +19,7 @@ import fs from "node:fs";
|
|
|
19
19
|
import path from "node:path";
|
|
20
20
|
import process from "node:process";
|
|
21
21
|
import { tryImportFromApp } from "../build/resolve-peer.mjs";
|
|
22
|
+
import { IMAGE_CACHE_BYTE_BUDGET } from "../config/defaults.js";
|
|
22
23
|
import { getConfig } from "../config/index.js";
|
|
23
24
|
|
|
24
25
|
/** @type {typeof import('sharp') | null | undefined} */
|
|
@@ -27,8 +28,96 @@ let sharpModule;
|
|
|
27
28
|
/** @type {string | null} */
|
|
28
29
|
let cacheDir = null;
|
|
29
30
|
|
|
31
|
+
/**
|
|
32
|
+
* Bilinen dosyalar. HIT `existsSync` yerine bu kümeye bakar; açılışta dizin
|
|
33
|
+
* bir kez okunur. Değer yazım zamanı ve bayttır — tahliye en eskiden.
|
|
34
|
+
*
|
|
35
|
+
* @type {Map<string, { bytes: number, mtimeMs: number }>}
|
|
36
|
+
*/
|
|
37
|
+
const imageCacheIndex = new Map();
|
|
38
|
+
|
|
39
|
+
/** @type {number} */
|
|
40
|
+
let imageCacheBytes = 0;
|
|
41
|
+
|
|
30
42
|
const MAX_REDIRECTS = 5;
|
|
31
43
|
|
|
44
|
+
/**
|
|
45
|
+
* @param {string} dir
|
|
46
|
+
* @returns {void}
|
|
47
|
+
*/
|
|
48
|
+
function loadImageCacheIndex(dir) {
|
|
49
|
+
imageCacheIndex.clear();
|
|
50
|
+
imageCacheBytes = 0;
|
|
51
|
+
|
|
52
|
+
let names;
|
|
53
|
+
try {
|
|
54
|
+
names = fs.readdirSync(dir);
|
|
55
|
+
} catch {
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
for (const name of names) {
|
|
60
|
+
if (!name.endsWith(".webp")) continue;
|
|
61
|
+
const filePath = path.join(dir, name);
|
|
62
|
+
try {
|
|
63
|
+
const stat = fs.statSync(filePath);
|
|
64
|
+
if (!stat.isFile()) continue;
|
|
65
|
+
imageCacheIndex.set(filePath, { bytes: stat.size, mtimeMs: stat.mtimeMs });
|
|
66
|
+
imageCacheBytes += stat.size;
|
|
67
|
+
} catch {
|
|
68
|
+
// Yarım tmp veya silinmiş dosya indeksi bozmasın.
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
evictImageCache("");
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* @param {string} filePath
|
|
77
|
+
* @param {number} bytes
|
|
78
|
+
* @returns {void}
|
|
79
|
+
*/
|
|
80
|
+
function noteImageCacheFile(filePath, bytes) {
|
|
81
|
+
const prev = imageCacheIndex.get(filePath);
|
|
82
|
+
if (prev) imageCacheBytes -= prev.bytes;
|
|
83
|
+
imageCacheIndex.set(filePath, { bytes, mtimeMs: Date.now() });
|
|
84
|
+
imageCacheBytes += bytes;
|
|
85
|
+
evictImageCache(filePath);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Tavan aşılınca en eski dosyayı siler. `protect` az önce yazılan yoldur:
|
|
90
|
+
* bu yanıt onu hâlâ okuyacak, bu turda düşmez.
|
|
91
|
+
*
|
|
92
|
+
* @param {string} protect
|
|
93
|
+
* @returns {void}
|
|
94
|
+
*/
|
|
95
|
+
function evictImageCache(protect) {
|
|
96
|
+
while (imageCacheBytes > IMAGE_CACHE_BYTE_BUDGET && imageCacheIndex.size > 1) {
|
|
97
|
+
let oldestPath = "";
|
|
98
|
+
let oldestMtime = Infinity;
|
|
99
|
+
let oldestBytes = 0;
|
|
100
|
+
|
|
101
|
+
for (const [filePath, meta] of imageCacheIndex) {
|
|
102
|
+
if (filePath === protect) continue;
|
|
103
|
+
if (meta.mtimeMs < oldestMtime) {
|
|
104
|
+
oldestMtime = meta.mtimeMs;
|
|
105
|
+
oldestPath = filePath;
|
|
106
|
+
oldestBytes = meta.bytes;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if (!oldestPath) break;
|
|
111
|
+
imageCacheIndex.delete(oldestPath);
|
|
112
|
+
imageCacheBytes = Math.max(0, imageCacheBytes - oldestBytes);
|
|
113
|
+
try {
|
|
114
|
+
fs.rmSync(oldestPath, { force: true });
|
|
115
|
+
} catch {
|
|
116
|
+
// Dosya yoksa indeks yine de düşmüş olsun.
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
32
121
|
/**
|
|
33
122
|
* @returns {import('../config/index.js').ImagesRemoteConfig | null}
|
|
34
123
|
*/
|
|
@@ -214,6 +303,7 @@ export async function mountImageOptimizer(app) {
|
|
|
214
303
|
const config = getConfig();
|
|
215
304
|
cacheDir = path.join(config.dirs.generated, "image-cache");
|
|
216
305
|
fs.mkdirSync(cacheDir, { recursive: true });
|
|
306
|
+
loadImageCacheIndex(cacheDir);
|
|
217
307
|
|
|
218
308
|
sharpModule = await tryImportFromApp(config.root, "sharp");
|
|
219
309
|
if (!sharpModule) {
|
|
@@ -260,7 +350,7 @@ async function handleOptimize(req, res, remote) {
|
|
|
260
350
|
const filePath = path.join(/** @type {string} */ (cacheDir), `${key}.webp`);
|
|
261
351
|
|
|
262
352
|
try {
|
|
263
|
-
if (
|
|
353
|
+
if (imageCacheIndex.has(filePath)) {
|
|
264
354
|
sendCached(res, filePath, remote.cacheMaxAge);
|
|
265
355
|
return;
|
|
266
356
|
}
|
|
@@ -286,7 +376,10 @@ async function handleOptimize(req, res, remote) {
|
|
|
286
376
|
const tmp = `${filePath}.${process.pid}.tmp`;
|
|
287
377
|
await fs.promises.writeFile(tmp, buffer);
|
|
288
378
|
await fs.promises.rename(tmp, filePath);
|
|
289
|
-
|
|
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);
|
|
290
383
|
sendCached(res, filePath, remote.cacheMaxAge);
|
|
291
384
|
} catch (error) {
|
|
292
385
|
console.warn("[images.remote] optimize failed:", error);
|
|
@@ -1,66 +1,193 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* Kısa ömürlü dosya logu.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
4
|
+
* Satırlar ~1 sn veya 32 satırda bir zstd NDJSON parçası olur. Diskte en
|
|
5
|
+
* fazla 5 dakika kalır; süresi dolan en eski parça silinir. `drainLog`
|
|
6
|
+
* her mühürlenen parçayı alır — hook hata verse de site düşmez, dosya
|
|
7
|
+
* süresi dolana kadar durur.
|
|
8
|
+
*
|
|
9
|
+
* Yol `path.resolve(root, dir)` ile bulunur. `getConfig().dirs` şişirilmez;
|
|
10
|
+
* log dizini opsiyoneldir ve çoğu kurulumda hiç açılmaz.
|
|
6
11
|
*/
|
|
7
12
|
import fs from "node:fs/promises";
|
|
8
13
|
import path from "node:path";
|
|
14
|
+
import zlib from "node:zlib";
|
|
9
15
|
|
|
10
16
|
/**
|
|
17
|
+
* @typedef {{ body: Buffer, encoding: "zstd", bytes: number, lines: number,
|
|
18
|
+
* at: number }} LogChunk
|
|
11
19
|
* @typedef {{ write: (entry: Record<string, unknown>) => Promise<void>,
|
|
12
20
|
* flush: () => Promise<void>, close: () => Promise<void> }} LogSink
|
|
21
|
+
* @typedef {(chunk: LogChunk) => void | Promise<void>} DrainLog
|
|
13
22
|
*/
|
|
14
23
|
|
|
24
|
+
/** Satır satır yazmak yerine kısa tampon. */
|
|
25
|
+
const FLUSH_INTERVAL_MS = 1000;
|
|
26
|
+
const FLUSH_MAX_LINES = 32;
|
|
27
|
+
|
|
28
|
+
/** Diskteki parçaların ömrü. Config yükseltemez. */
|
|
29
|
+
export const FILE_LOG_RETENTION_MS = 5 * 60 * 1000;
|
|
30
|
+
|
|
15
31
|
/**
|
|
16
|
-
* @param {{ root: string, dir: string
|
|
32
|
+
* @param {{ root: string, dir: string, persist?: boolean,
|
|
33
|
+
* retentionMs?: number, drainLog?: DrainLog | null }} options
|
|
34
|
+
* `persist: false` diske yazmaz; yalnız `drainLog` çağrılır.
|
|
17
35
|
* @returns {LogSink}
|
|
18
36
|
*/
|
|
19
37
|
export function createFileSink(options) {
|
|
38
|
+
const persist = options.persist !== false;
|
|
39
|
+
const retentionMs = options.retentionMs ?? FILE_LOG_RETENTION_MS;
|
|
40
|
+
const drainLog = typeof options.drainLog === "function" ? options.drainLog : null;
|
|
20
41
|
const baseDir = path.resolve(options.root, options.dir);
|
|
21
|
-
/** @type {string
|
|
22
|
-
let
|
|
23
|
-
/** @type {
|
|
24
|
-
let
|
|
42
|
+
/** @type {string[]} */
|
|
43
|
+
let pending = [];
|
|
44
|
+
/** @type {ReturnType<typeof setTimeout> | null} */
|
|
45
|
+
let timer = null;
|
|
46
|
+
/** @type {Promise<void>} */
|
|
47
|
+
let chain = Promise.resolve();
|
|
48
|
+
let seq = 0;
|
|
25
49
|
let ready = false;
|
|
50
|
+
let drainWarned = false;
|
|
26
51
|
|
|
27
|
-
/**
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
return
|
|
52
|
+
/**
|
|
53
|
+
* @param {string} message
|
|
54
|
+
* @param {unknown} [error]
|
|
55
|
+
*/
|
|
56
|
+
function warnDrain(message, error) {
|
|
57
|
+
if (drainWarned) return;
|
|
58
|
+
drainWarned = true;
|
|
59
|
+
console.warn(`[logs] ${message}`, error ?? "");
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* @returns {Promise<void>}
|
|
64
|
+
*/
|
|
65
|
+
async function ensureDir() {
|
|
66
|
+
if (ready) return;
|
|
67
|
+
await fs.mkdir(baseDir, { recursive: true });
|
|
68
|
+
ready = true;
|
|
33
69
|
}
|
|
34
70
|
|
|
35
71
|
/**
|
|
36
|
-
*
|
|
37
|
-
*
|
|
72
|
+
* Süresi dolmuş `jskelet-*` parçalarını eskiden yeniye siler. Başka
|
|
73
|
+
* dosyalara dokunulmaz. Eski günlük `.log` dosyaları da bu ada uyar.
|
|
74
|
+
*
|
|
75
|
+
* @returns {Promise<void>}
|
|
38
76
|
*/
|
|
39
|
-
async function
|
|
40
|
-
if (!
|
|
41
|
-
|
|
42
|
-
|
|
77
|
+
async function prune() {
|
|
78
|
+
if (!persist) return;
|
|
79
|
+
let names;
|
|
80
|
+
try {
|
|
81
|
+
names = await fs.readdir(baseDir);
|
|
82
|
+
} catch {
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const now = Date.now();
|
|
87
|
+
/** @type {{ file: string, mtimeMs: number }[]} */
|
|
88
|
+
const doomed = [];
|
|
89
|
+
|
|
90
|
+
for (const name of names) {
|
|
91
|
+
if (!name.startsWith("jskelet-")) continue;
|
|
92
|
+
const file = path.join(baseDir, name);
|
|
93
|
+
try {
|
|
94
|
+
const stat = await fs.stat(file);
|
|
95
|
+
if (!stat.isFile()) continue;
|
|
96
|
+
if (now - stat.mtimeMs < retentionMs) continue;
|
|
97
|
+
doomed.push({ file, mtimeMs: stat.mtimeMs });
|
|
98
|
+
} catch {
|
|
99
|
+
// Silinmiş veya okunamayan girdi turu bozmasın.
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
doomed.sort((a, b) => a.mtimeMs - b.mtimeMs);
|
|
104
|
+
for (const item of doomed) {
|
|
105
|
+
await fs.rm(item.file, { force: true });
|
|
43
106
|
}
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* @param {string} text
|
|
111
|
+
* @param {number} lines
|
|
112
|
+
* @returns {Promise<void>}
|
|
113
|
+
*/
|
|
114
|
+
async function seal(text, lines) {
|
|
115
|
+
const body = zlib.zstdCompressSync(Buffer.from(text, "utf8"));
|
|
116
|
+
const at = Date.now();
|
|
117
|
+
|
|
118
|
+
if (persist) {
|
|
119
|
+
await ensureDir();
|
|
120
|
+
seq += 1;
|
|
121
|
+
const file = path.join(baseDir, `jskelet-${at}-${seq}.ndjson.zst`);
|
|
122
|
+
await fs.writeFile(file, body);
|
|
123
|
+
await prune();
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if (!drainLog) return;
|
|
127
|
+
|
|
128
|
+
try {
|
|
129
|
+
await drainLog({
|
|
130
|
+
body,
|
|
131
|
+
encoding: "zstd",
|
|
132
|
+
bytes: body.length,
|
|
133
|
+
lines,
|
|
134
|
+
at,
|
|
135
|
+
});
|
|
136
|
+
} catch (error) {
|
|
137
|
+
warnDrain(
|
|
138
|
+
persist
|
|
139
|
+
? "drainLog failed; the chunk stays on disk until it expires"
|
|
140
|
+
: "drainLog failed; the chunk was not stored",
|
|
141
|
+
error,
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* @returns {Promise<void>}
|
|
148
|
+
*/
|
|
149
|
+
async function flushPending() {
|
|
150
|
+
if (timer) {
|
|
151
|
+
clearTimeout(timer);
|
|
152
|
+
timer = null;
|
|
153
|
+
}
|
|
154
|
+
if (!pending.length) return;
|
|
155
|
+
|
|
156
|
+
const lines = pending;
|
|
157
|
+
pending = [];
|
|
158
|
+
await seal(lines.join(""), lines.length);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** @returns {void} */
|
|
162
|
+
function schedule() {
|
|
163
|
+
if (timer || !pending.length) return;
|
|
164
|
+
timer = setTimeout(() => {
|
|
165
|
+
timer = null;
|
|
166
|
+
chain = chain.then(() => flushPending());
|
|
167
|
+
}, FLUSH_INTERVAL_MS);
|
|
168
|
+
timer.unref?.();
|
|
48
169
|
}
|
|
49
170
|
|
|
50
171
|
return {
|
|
51
172
|
async write(entry) {
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
)
|
|
55
|
-
|
|
56
|
-
|
|
173
|
+
pending.push(`${JSON.stringify(entry)}\n`);
|
|
174
|
+
|
|
175
|
+
if (pending.length >= FLUSH_MAX_LINES) {
|
|
176
|
+
chain = chain.then(() => flushPending());
|
|
177
|
+
await chain;
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
schedule();
|
|
57
182
|
},
|
|
58
183
|
async flush() {
|
|
59
|
-
|
|
184
|
+
chain = chain.then(() => flushPending());
|
|
185
|
+
await chain;
|
|
60
186
|
},
|
|
61
187
|
async close() {
|
|
62
|
-
|
|
63
|
-
|
|
188
|
+
chain = chain.then(() => flushPending());
|
|
189
|
+
await chain;
|
|
190
|
+
if (persist) await prune();
|
|
64
191
|
},
|
|
65
192
|
};
|
|
66
193
|
}
|
|
@@ -36,7 +36,7 @@ let accessMounted = false;
|
|
|
36
36
|
*/
|
|
37
37
|
export function shouldMountAccessLog(logs) {
|
|
38
38
|
if (!logs.kinds.includes("http")) return false;
|
|
39
|
-
if (logs.file.enabled || logs.s3.enabled) return true;
|
|
39
|
+
if (logs.file.enabled || logs.s3.enabled || logs.drainLog) return true;
|
|
40
40
|
return logs.console && process.env.NODE_ENV !== "development";
|
|
41
41
|
}
|
|
42
42
|
|
|
@@ -61,8 +61,15 @@ export async function configureLogs(config) {
|
|
|
61
61
|
/** @type {LogSink[]} */
|
|
62
62
|
const next = [];
|
|
63
63
|
|
|
64
|
-
if (logs.file.enabled) {
|
|
65
|
-
next.push(
|
|
64
|
+
if (logs.file.enabled || logs.drainLog) {
|
|
65
|
+
next.push(
|
|
66
|
+
createFileSink({
|
|
67
|
+
root: config.root,
|
|
68
|
+
dir: logs.file.dir,
|
|
69
|
+
persist: logs.file.enabled,
|
|
70
|
+
drainLog: logs.drainLog,
|
|
71
|
+
}),
|
|
72
|
+
);
|
|
66
73
|
}
|
|
67
74
|
|
|
68
75
|
if (logs.s3.enabled) {
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
*/
|
|
11
11
|
import fs from "node:fs";
|
|
12
12
|
import path from "node:path";
|
|
13
|
+
import process from "node:process";
|
|
13
14
|
import { negotiateEncoding } from "./compression.js";
|
|
14
15
|
import { IMMUTABLE_CACHE } from "../../config/defaults.js";
|
|
15
16
|
|
|
@@ -31,6 +32,33 @@ const CONTENT_TYPES = {
|
|
|
31
32
|
*/
|
|
32
33
|
export function staticPrecompressed(publicDir) {
|
|
33
34
|
const root = path.resolve(publicDir);
|
|
35
|
+
// Hash'li `/assets/` süreç ömründe değişmez. Üretimde olumlu ve olumsuz
|
|
36
|
+
// `stat` sonucu bellekte kalır; dev'de watch hash değiştirdiği için yok.
|
|
37
|
+
const cacheStats = process.env.NODE_ENV !== "development";
|
|
38
|
+
/** @type {Map<string, number | null>} yol → bayt, `null` → dosya yok */
|
|
39
|
+
const statCache = new Map();
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* @param {string} file
|
|
43
|
+
* @returns {number | null}
|
|
44
|
+
*/
|
|
45
|
+
function fileSize(file) {
|
|
46
|
+
if (cacheStats && statCache.has(file)) {
|
|
47
|
+
return /** @type {number | null} */ (statCache.get(file));
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** @type {number | null} */
|
|
51
|
+
let size = null;
|
|
52
|
+
try {
|
|
53
|
+
const stat = fs.statSync(file);
|
|
54
|
+
if (stat.isFile()) size = stat.size;
|
|
55
|
+
} catch {
|
|
56
|
+
size = null;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (cacheStats) statCache.set(file, size);
|
|
60
|
+
return size;
|
|
61
|
+
}
|
|
34
62
|
|
|
35
63
|
return (req, res, next) => {
|
|
36
64
|
if (req.method !== "GET" && req.method !== "HEAD") {
|
|
@@ -67,23 +95,16 @@ export function staticPrecompressed(publicDir) {
|
|
|
67
95
|
}
|
|
68
96
|
|
|
69
97
|
const file = `${target}${encoding === "br" ? ".br" : ".gz"}`;
|
|
98
|
+
const size = fileSize(file);
|
|
70
99
|
|
|
71
|
-
|
|
72
|
-
try {
|
|
73
|
-
stat = fs.statSync(file);
|
|
74
|
-
} catch {
|
|
75
|
-
next();
|
|
76
|
-
return;
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
if (!stat.isFile()) {
|
|
100
|
+
if (size == null) {
|
|
80
101
|
next();
|
|
81
102
|
return;
|
|
82
103
|
}
|
|
83
104
|
|
|
84
105
|
res.setHeader("Content-Type", type);
|
|
85
106
|
res.setHeader("Content-Encoding", encoding);
|
|
86
|
-
res.setHeader("Content-Length", String(
|
|
107
|
+
res.setHeader("Content-Length", String(size));
|
|
87
108
|
res.setHeader("Vary", "Accept-Encoding");
|
|
88
109
|
|
|
89
110
|
if (req.path.startsWith("/assets/")) {
|