jskelet 0.3.0 → 0.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -2,9 +2,12 @@
2
2
  * Admin paneli cache / host / process dökümü.
3
3
  *
4
4
  * HTML gövdesi ve veri değerleri dönmez — yalnızca meta.
5
+ * System sayfasındaki çubuklar **bu sürecin** payını gösterir (makine
6
+ * toplamına oranla), host'un genel doluluğunu değil.
5
7
  */
6
8
  import fs from "node:fs";
7
9
  import os from "node:os";
10
+ import path from "node:path";
8
11
  import process from "node:process";
9
12
  import { getConfig } from "../../config/index.js";
10
13
  import {
@@ -29,8 +32,25 @@ import { prewarmProgress } from "../prewarm.js";
29
32
  /** Listelerin üst sınırı: veri önbelleğinde on binlerce anahtar olabiliyor. */
30
33
  const MAX_LISTED = 500;
31
34
 
35
+ /** Disk ölçümünde atlanan dizinler — node_modules her poll'da taranmasın. */
36
+ const SKIP_DIRS = new Set([
37
+ "node_modules",
38
+ ".git",
39
+ ".hg",
40
+ ".svn",
41
+ "coverage",
42
+ ".turbo",
43
+ ".next",
44
+ ]);
45
+
32
46
  export const BOOT_ID = `${Date.now()}-${process.pid}`;
33
47
 
48
+ /** @type {{ usage: NodeJS.CpuUsage; at: number } | null} */
49
+ let cpuSample = null;
50
+
51
+ /** @type {{ at: number, bytes: number, root: string } | null} */
52
+ let diskSample = null;
53
+
34
54
  /**
35
55
  * @param {string} query Anahtar filtresi (boş → filtre yok).
36
56
  * @returns {Promise<object>}
@@ -46,6 +66,9 @@ export async function snapshot(query) {
46
66
  const htmlMatched = html.filter(matches);
47
67
  const dataMatched = data.filter(matches);
48
68
  const usage = process.memoryUsage();
69
+ const host = await hostStatus();
70
+ const cpuPercent = sampleProcessCpu(host.cpus);
71
+ const appDiskBytes = await sampleAppDisk(getConfig().root);
49
72
 
50
73
  return {
51
74
  boot: BOOT_ID,
@@ -56,13 +79,17 @@ export async function snapshot(query) {
56
79
  node: FRAMEWORK_NODE_RANGE,
57
80
  homepage: FRAMEWORK_HOMEPAGE,
58
81
  },
59
- host: await hostStatus(),
82
+ host,
60
83
  process: {
61
84
  pid: process.pid,
62
85
  node: process.version,
63
86
  uptime: process.uptime(),
64
87
  env: process.env.NODE_ENV ?? "production",
65
88
  memory: { rss: usage.rss, heapUsed: usage.heapUsed },
89
+ /** 0–100: bu sürecin tüm çekirdek kapasitesine oranı. */
90
+ cpuPercent,
91
+ /** Proje kökünün (node_modules hariç) disk boyutu. */
92
+ diskBytes: appDiskBytes,
66
93
  },
67
94
  html: {
68
95
  size: getHtmlCacheSize(),
@@ -94,6 +121,94 @@ export async function snapshot(query) {
94
121
  };
95
122
  }
96
123
 
124
+ /**
125
+ * İki örnek arasındaki `process.cpuUsage` farkından süreç CPU yüzdesi.
126
+ * İlk çağrıda örnek alınır ve 0 döner — panelin bir sonraki turunda anlamlı.
127
+ *
128
+ * @param {number} cores
129
+ * @returns {number}
130
+ */
131
+ function sampleProcessCpu(cores) {
132
+ const now = Date.now();
133
+ const current = process.cpuUsage();
134
+
135
+ if (!cpuSample) {
136
+ cpuSample = { usage: current, at: now };
137
+ return 0;
138
+ }
139
+
140
+ const elapsedMs = now - cpuSample.at;
141
+ const delta = process.cpuUsage(cpuSample.usage);
142
+ cpuSample = { usage: current, at: now };
143
+
144
+ if (elapsedMs <= 0 || cores <= 0) return 0;
145
+
146
+ // user+system mikro-saniye → ms; duvar süresi × çekirdek = kapasite.
147
+ const cpuMs = (delta.user + delta.system) / 1000;
148
+ const capacityMs = elapsedMs * cores;
149
+ return Math.max(0, Math.min(100, (cpuMs / capacityMs) * 100));
150
+ }
151
+
152
+ /**
153
+ * Proje kökünün boyutu (ağır dizinler atlanır). 60 sn önbellek.
154
+ *
155
+ * @param {string} root
156
+ * @returns {Promise<number>}
157
+ */
158
+ async function sampleAppDisk(root) {
159
+ if (diskSample && diskSample.root === root && Date.now() - diskSample.at < 60_000) {
160
+ return diskSample.bytes;
161
+ }
162
+
163
+ const bytes = await measureDir(root);
164
+ diskSample = { at: Date.now(), bytes, root };
165
+ return bytes;
166
+ }
167
+
168
+ /**
169
+ * @param {string} dir
170
+ * @returns {Promise<number>}
171
+ */
172
+ async function measureDir(dir) {
173
+ let total = 0;
174
+
175
+ /** @type {string[]} */
176
+ const stack = [dir];
177
+
178
+ while (stack.length) {
179
+ const current = stack.pop();
180
+ if (!current) continue;
181
+
182
+ let entries;
183
+ try {
184
+ entries = await fs.promises.readdir(current, { withFileTypes: true });
185
+ } catch {
186
+ continue;
187
+ }
188
+
189
+ for (const entry of entries) {
190
+ if (entry.name === "." || entry.name === "..") continue;
191
+ if (entry.isDirectory() && SKIP_DIRS.has(entry.name)) continue;
192
+
193
+ const full = path.join(current, entry.name);
194
+ if (entry.isDirectory()) {
195
+ stack.push(full);
196
+ continue;
197
+ }
198
+ if (!entry.isFile()) continue;
199
+
200
+ try {
201
+ const stats = await fs.promises.stat(full);
202
+ total += stats.size;
203
+ } catch {
204
+ // silinmiş / erişilemeyen dosya
205
+ }
206
+ }
207
+ }
208
+
209
+ return total;
210
+ }
211
+
97
212
  /**
98
213
  * @returns {Promise<object>}
99
214
  */
@@ -10,6 +10,8 @@
10
10
  * 3. headers → devGate → redirects → trailingSlash — gate'in 404'ü
11
11
  * redirect'ten önce; trailingSlash config redirects'ten sonra, böylece
12
12
  * açık kurallar istenen yolu önce görür.
13
+ * 3b. access log (açıksa) — tamamlanan yanıtların süresi; admin/prewarm
14
+ * içeride elenir.
13
15
  * 4. staticPrecompressed → express.static — build'de üretilmiş `.br`/`.gz`
14
16
  * kopyalar varsa onlar servis edilir (kalite 11), yoksa istek altındaki
15
17
  * static'e düşer ve middleware anında sıkıştırır (kalite 5).
@@ -42,6 +44,7 @@ import { isPrewarmRequest, notePrewarmError, startPrewarm } from "./prewarm.js";
42
44
  import { trackUpstreamFetch } from "./upstream-tracking.js";
43
45
  import { configureUpstreamLimiter } from "./upstream-limiter.js";
44
46
  import { connectRedis, disconnectRedis } from "./redis.js";
47
+ import { configureLogs, flushLogs, closeLogs } from "./logs/pipeline.js";
45
48
  import { isNotFoundError, isRedirectError } from "../http/control-flow.js";
46
49
 
47
50
  /**
@@ -68,6 +71,10 @@ export async function createApp(options = {}) {
68
71
  // dokunmayan, tamamen opsiyonel bir adım.
69
72
  await connectRedis(config);
70
73
 
74
+ // Kalıcı log sink'leri (dosya / S3). Credential eksikse uyarı + no-op;
75
+ // site düşmez. Access middleware gerektiğinde biraz aşağıda mount edilir.
76
+ const { accessLog } = await configureLogs(config);
77
+
71
78
  const app = express();
72
79
 
73
80
  app.disable("x-powered-by");
@@ -90,6 +97,13 @@ export async function createApp(options = {}) {
90
97
  app.use(redirects());
91
98
  app.use(trailingSlash());
92
99
 
100
+ // Access log: headers/redirects sonrası, statikten önce — böylece
101
+ // tamamlanan her yanıt (304 dahil) süre alır; admin/prewarm içeride elenir.
102
+ if (accessLog) {
103
+ const { accessLogMiddleware } = await import("./logs/access-middleware.js");
104
+ app.use(accessLogMiddleware());
105
+ }
106
+
93
107
  app.use(staticPrecompressed(config.dirs.public));
94
108
  app.use(
95
109
  express.static(config.dirs.public, {
@@ -248,8 +262,10 @@ export async function startServer(options = {}) {
248
262
  /**
249
263
  * `SIGTERM`/`SIGINT` sonrası düzenli kapanış.
250
264
  *
251
- * Kapatılması gereken tek dış bağlantı Redis ve `quit` uçuştaki komutların
252
- * bitmesini bekliyor; sert `disconnect` yarıda kalan bir `SET` bırakabiliyor.
265
+ * Kapatılması gereken dış kaynaklar Redis ve log sink buffer'ları. Redis
266
+ * `quit` uçuştaki komutların bitmesini bekliyor; sert `disconnect` yarıda
267
+ * kalan bir `SET` bırakabiliyor. S3 sink kapanışta kalan batch'i PutObject
268
+ * ile gönderir.
253
269
  *
254
270
  * Açık HTTP bağlantıları **beklenmez**. `close()` tek başına yalnızca yeni
255
271
  * bağlantıyı reddediyor; keep-alive bir istemci ya da dev panelinin
@@ -276,10 +292,11 @@ function attachShutdown(server) {
276
292
  server.close();
277
293
  server.closeAllConnections?.();
278
294
 
279
- void disconnectRedis().finally(() => {
280
- clearTimeout(timer);
281
- process.exit(0);
282
- });
295
+ void Promise.all([disconnectRedis(), flushLogs().then(() => closeLogs())])
296
+ .finally(() => {
297
+ clearTimeout(timer);
298
+ process.exit(0);
299
+ });
283
300
  };
284
301
 
285
302
  process.once("SIGTERM", shutdown);
@@ -0,0 +1,66 @@
1
+ /**
2
+ * HTTP access log middleware.
3
+ *
4
+ * Admin ve prewarm yolları atlanır. Kayıt `log.http` ile stdout'a (console
5
+ * açıksa) ve `acceptLogEntry` ile sink'lere gider. `emitHttp` kapalı olduğu
6
+ * için `log.http` abonelere ikinci kez yazmaz.
7
+ */
8
+ import * as log from "../../log.mjs";
9
+ import { getConfig } from "../../config/index.js";
10
+ import { acceptLogEntry } from "./pipeline.js";
11
+
12
+ /**
13
+ * @param {{ basePath?: string }} [options]
14
+ * @returns {import('express').RequestHandler}
15
+ */
16
+ export function accessLogMiddleware(options = {}) {
17
+ const config = getConfig();
18
+ const brand = config.brand;
19
+ const basePath =
20
+ options.basePath ??
21
+ (config.admin.enabled ? config.admin.basePath : null);
22
+
23
+ /** @type {import('express').RequestHandler} */
24
+ return (req, res, next) => {
25
+ const pathname = req.path || "";
26
+ if (basePath && (pathname === basePath || pathname.startsWith(`${basePath}/`))) {
27
+ return next();
28
+ }
29
+
30
+ if (req.headers["user-agent"] === brand.prewarmUserAgent) {
31
+ return next();
32
+ }
33
+
34
+ const started = process.hrtime.bigint();
35
+
36
+ res.on("finish", () => {
37
+ const ms = Number(process.hrtime.bigint() - started) / 1e6;
38
+ const cache = /** @type {string | null} */ (
39
+ res.getHeader(brand.cacheHeader) ?? null
40
+ );
41
+
42
+ const info = {
43
+ method: req.method,
44
+ url: req.originalUrl,
45
+ status: res.statusCode,
46
+ ms,
47
+ cache,
48
+ };
49
+
50
+ // Stdout (console açıksa). emitHttp kapalı → subscribe çiftlemez.
51
+ log.http(info);
52
+
53
+ acceptLogEntry({
54
+ kind: "http",
55
+ method: info.method,
56
+ url: info.url,
57
+ path: req.path,
58
+ status: info.status,
59
+ ms: info.ms,
60
+ cache: info.cache,
61
+ });
62
+ });
63
+
64
+ next();
65
+ };
66
+ }
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Günlük rotasyonlu NDJSON dosya sink'i.
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.
6
+ */
7
+ import fs from "node:fs/promises";
8
+ import path from "node:path";
9
+
10
+ /**
11
+ * @typedef {{ write: (entry: Record<string, unknown>) => Promise<void>,
12
+ * flush: () => Promise<void>, close: () => Promise<void> }} LogSink
13
+ */
14
+
15
+ /**
16
+ * @param {{ root: string, dir: string }} options
17
+ * @returns {LogSink}
18
+ */
19
+ export function createFileSink(options) {
20
+ const baseDir = path.resolve(options.root, options.dir);
21
+ /** @type {string | null} */
22
+ let currentDay = null;
23
+ /** @type {string | null} */
24
+ let currentPath = null;
25
+ let ready = false;
26
+
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}`;
33
+ }
34
+
35
+ /**
36
+ * @param {string} day
37
+ * @returns {Promise<string>}
38
+ */
39
+ async function ensureFile(day) {
40
+ if (!ready) {
41
+ await fs.mkdir(baseDir, { recursive: true });
42
+ ready = true;
43
+ }
44
+ if (currentDay === day && currentPath) return currentPath;
45
+ currentDay = day;
46
+ currentPath = path.join(baseDir, `jskelet-${day}.log`);
47
+ return currentPath;
48
+ }
49
+
50
+ return {
51
+ 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");
57
+ },
58
+ async flush() {
59
+ // appendFile senkron flush eder; ekstra iş yok.
60
+ },
61
+ async close() {
62
+ currentDay = null;
63
+ currentPath = null;
64
+ },
65
+ };
66
+ }
@@ -0,0 +1,164 @@
1
+ /**
2
+ * Structured log pipeline: `log.subscribe` → kinds filtresi → file / S3.
3
+ *
4
+ * Sink yokken abone olunmaz: `emitLog` boş Set üzerinde döner ve stdout
5
+ * yoluna maliyet eklemez. Credential eksikse S3 açılmaz, site ayağa kalkar.
6
+ */
7
+ import process from "node:process";
8
+ import * as log from "../../log.mjs";
9
+ import { createFileSink } from "./file-sink.js";
10
+ import { createS3Sink } from "./s3-sink.js";
11
+
12
+ /**
13
+ * @typedef {import('../../config/index.js').LogsConfig} LogsConfig
14
+ * @typedef {import('../../config/index.js').ResolvedConfig} ResolvedConfig
15
+ * @typedef {import('./file-sink.js').LogSink} LogSink
16
+ */
17
+
18
+ /** @type {(() => void) | null} */
19
+ let unsubscribe = null;
20
+ /** @type {LogSink[]} */
21
+ let sinks = [];
22
+ /** @type {Set<string>} */
23
+ let kindSet = new Set();
24
+ let accessMounted = false;
25
+
26
+ /**
27
+ * @returns {import('./s3-put.js').AwsCredentials | null}
28
+ */
29
+ function readCredentials() {
30
+ const accessKeyId = process.env.AWS_ACCESS_KEY_ID;
31
+ const secretAccessKey = process.env.AWS_SECRET_ACCESS_KEY;
32
+ if (!accessKeyId || !secretAccessKey) return null;
33
+ return {
34
+ accessKeyId,
35
+ secretAccessKey,
36
+ sessionToken: process.env.AWS_SESSION_TOKEN || null,
37
+ };
38
+ }
39
+
40
+ /**
41
+ * Access middleware mount edilmeli mi?
42
+ *
43
+ * Dev'de yalnızca console isteniyorsa mount edilmez — HTML satırlarını
44
+ * zaten devtools basıyor; çift kayıt olmasın. Sink açıksa ya da production
45
+ * console access log istiyorsa mount edilir.
46
+ *
47
+ * @param {LogsConfig} logs
48
+ * @returns {boolean}
49
+ */
50
+ export function shouldMountAccessLog(logs) {
51
+ if (!logs.kinds.includes("http")) return false;
52
+ if (logs.file.enabled || logs.s3.enabled) return true;
53
+ return logs.console && process.env.NODE_ENV !== "development";
54
+ }
55
+
56
+ /**
57
+ * @param {ResolvedConfig} config
58
+ * @returns {Promise<{ accessLog: boolean }>}
59
+ */
60
+ export async function configureLogs(config) {
61
+ await closeLogs();
62
+
63
+ const logs = config.logs;
64
+ kindSet = new Set(logs.kinds);
65
+ accessMounted = shouldMountAccessLog(logs);
66
+
67
+ log.configureLog({
68
+ console: logs.console,
69
+ // Access middleware HTTP'yi kendisi pipeline'a yazar; log.http yalnızca
70
+ // stdout için kalsın — aksi hâlde HTML istekleri (devtools) çiftlenir.
71
+ emitHttp: !accessMounted,
72
+ });
73
+
74
+ /** @type {LogSink[]} */
75
+ const next = [];
76
+
77
+ if (logs.file.enabled) {
78
+ next.push(createFileSink({ root: config.root, dir: logs.file.dir }));
79
+ }
80
+
81
+ if (logs.s3.enabled) {
82
+ const credentials = readCredentials();
83
+ if (!logs.s3.bucket) {
84
+ console.warn("[logs] s3.enabled but no bucket (set JSKELET_LOG_BUCKET or logs.s3.bucket)");
85
+ } else if (!logs.s3.region) {
86
+ console.warn("[logs] s3.enabled but no region (set AWS_REGION or logs.s3.region)");
87
+ } else if (!credentials) {
88
+ console.warn(
89
+ "[logs] s3.enabled but AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY missing — S3 sink disabled",
90
+ );
91
+ } else {
92
+ next.push(
93
+ createS3Sink({
94
+ bucket: logs.s3.bucket,
95
+ prefix: logs.s3.prefix,
96
+ region: logs.s3.region,
97
+ endpoint: logs.s3.endpoint,
98
+ credentials,
99
+ flushIntervalMs: logs.s3.flushIntervalMs,
100
+ maxBatch: logs.s3.maxBatch,
101
+ }),
102
+ );
103
+ }
104
+ }
105
+
106
+ sinks = next;
107
+
108
+ if (sinks.length) {
109
+ unsubscribe = log.subscribe((raw) => {
110
+ acceptLogEntry(raw);
111
+ });
112
+ }
113
+
114
+ return { accessLog: accessMounted };
115
+ }
116
+
117
+ /**
118
+ * Access middleware ve `log.subscribe` ortak giriş noktası.
119
+ *
120
+ * @param {Record<string, unknown>} raw
121
+ */
122
+ export function acceptLogEntry(raw) {
123
+ if (!sinks.length) return;
124
+
125
+ const kind = typeof raw.kind === "string" ? raw.kind : "event";
126
+ if (!kindSet.has(kind)) return;
127
+
128
+ const entry = {
129
+ ...raw,
130
+ kind,
131
+ at: typeof raw.at === "number" ? raw.at : Date.now(),
132
+ };
133
+
134
+ for (const sink of sinks) {
135
+ void sink.write(entry).catch((error) => {
136
+ console.warn("[logs] sink write failed", error);
137
+ });
138
+ }
139
+ }
140
+
141
+ /**
142
+ * Kapanışta buffer'ları boşaltır.
143
+ *
144
+ * @returns {Promise<void>}
145
+ */
146
+ export async function flushLogs() {
147
+ await Promise.all(sinks.map((sink) => sink.flush()));
148
+ }
149
+
150
+ /**
151
+ * @returns {Promise<void>}
152
+ */
153
+ export async function closeLogs() {
154
+ if (unsubscribe) {
155
+ unsubscribe();
156
+ unsubscribe = null;
157
+ }
158
+ const closing = sinks;
159
+ sinks = [];
160
+ await Promise.all(closing.map((sink) => sink.close()));
161
+ kindSet = new Set();
162
+ accessMounted = false;
163
+ log.configureLog({ console: true, emitHttp: true });
164
+ }