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
@@ -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 erişilemez olduğunda bu modül birebir eskisi
30
- * gibi çalışır.
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
- if (!redisShares("html")) return;
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
- if (!redisShares("html")) return null;
408
+ const onRedis = redisShares("html");
409
+ const onDisk = diskShares("html");
410
+ if (!onRedis && !onDisk) return null;
398
411
 
399
- const payload = await redisGetJson(cacheKey("html", key));
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
- void redisDropMatching("html", (key) =>
774
- matchers.some((matcher) => matcher(pathOf(key))),
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
- let marked = 0;
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 (fs.existsSync(filePath)) {
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
- * Günlük rotasyonlu NDJSON dosya sink'i.
2
+ * Kısa ömürlü dosya logu.
3
3
  *
4
- * Yol `path.resolve(root, dir)` ile bulunur — `getConfig().dirs` şişirilmez;
5
- * log dizini opsiyonel bir yüzey ve çoğu kurulumda hiç açılmaz.
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 }} options
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 | null} */
22
- let currentDay = null;
23
- /** @type {string | null} */
24
- let currentPath = null;
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
- /** @returns {string} */
28
- function dayStamp(date = new Date()) {
29
- const y = date.getUTCFullYear();
30
- const m = String(date.getUTCMonth() + 1).padStart(2, "0");
31
- const d = String(date.getUTCDate()).padStart(2, "0");
32
- return `${y}-${m}-${d}`;
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
- * @param {string} day
37
- * @returns {Promise<string>}
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 ensureFile(day) {
40
- if (!ready) {
41
- await fs.mkdir(baseDir, { recursive: true });
42
- ready = true;
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
- if (currentDay === day && currentPath) return currentPath;
45
- currentDay = day;
46
- currentPath = path.join(baseDir, `jskelet-${day}.log`);
47
- return currentPath;
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
- const day = dayStamp(
53
- typeof entry.at === "number" ? new Date(entry.at) : new Date(),
54
- );
55
- const file = await ensureFile(day);
56
- await fs.appendFile(file, `${JSON.stringify(entry)}\n`, "utf8");
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
- // appendFile senkron flush eder; ekstra iş yok.
184
+ chain = chain.then(() => flushPending());
185
+ await chain;
60
186
  },
61
187
  async close() {
62
- currentDay = null;
63
- currentPath = null;
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(createFileSink({ root: config.root, dir: logs.file.dir }));
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
- let stat;
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(stat.size));
107
+ res.setHeader("Content-Length", String(size));
87
108
  res.setHeader("Vary", "Accept-Encoding");
88
109
 
89
110
  if (req.path.startsWith("/assets/")) {