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.
package/CHANGELOG.md CHANGED
@@ -27,6 +27,12 @@ one is listed under a **Breaking** heading.
27
27
 
28
28
  ### Added
29
29
 
30
+ - Top-level `logs` config for persistent sinks: daily NDJSON files
31
+ (`logs.file`) and batched S3 PutObject (`logs.s3`) with embedded SigV4 — no
32
+ `@aws-sdk` dependency. `kinds` selects `http` / `event` / `error`; `console`
33
+ toggles runtime stdout lines. Missing bucket/region/credentials warn and
34
+ disable the S3 sink without taking the site down. Env: `JSKELET_LOG_BUCKET`,
35
+ `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, `AWS_SESSION_TOKEN`, `AWS_REGION`.
30
36
  - Admin panel pages under `/_jskelet/admin`: Overview, Cache, Routes, Views,
31
37
  Logs and System. Configurable `allowIps` (exact or CIDR), `blockBots` (default
32
38
  on — crawler UAs get 404 before login), and `logSize`. Live Logs use an
@@ -207,6 +213,11 @@ false`.
207
213
 
208
214
  ### Changed
209
215
 
216
+ - Admin panel System meters (CPU, memory, disk) show this process's share of
217
+ the host — RSS and project disk footprint against machine totals, plus
218
+ process CPU across all cores — instead of whole-machine fullness. The panel
219
+ content width is wider (`1600px`) so Overview, Routes, Views and System use
220
+ the screen better.
210
221
  - The release history page in `examples/marketing` now shows one release at a
211
222
  time: the newest one is expanded and older releases collapse to a single
212
223
  header row with their date, status and change count. Every release used to be
@@ -752,6 +752,46 @@ redis: {
752
752
  }
753
753
  ```
754
754
 
755
+ ### `logs`
756
+
757
+ Kalıcı log sink'leri. Varsayılan her şey kapalı: stdout ve admin paneli ring'i
758
+ mevcut davranışını korur. Açıldığında HTTP access log ile framework olayları
759
+ (`event` / `error`) NDJSON satırları olarak dosyaya ve/veya S3'e yazılır.
760
+
761
+ | Alan | Tip | Varsayılan | Anlamı |
762
+ | --- | --- | --- | --- |
763
+ | `console` | `boolean` | `true` | Runtime `http` / `event` / `error` satırları stdout'a basılsın mı (banner/build satırları etkilenmez) |
764
+ | `kinds` | `("http" \| "event" \| "error")[]` | hepsi | Sink'lere giden kayıt türleri |
765
+ | `file.enabled` | `boolean` | `false` | Günlük dosya sink'i |
766
+ | `file.dir` | `string` | `"logs"` | Proje köküne göre dizin; `jskelet-YYYY-MM-DD.log` |
767
+ | `file.rotate` | `"daily"` | `"daily"` | Yalnızca günlük rotasyon |
768
+ | `s3.enabled` | `boolean` | `false` | S3 batch PutObject sink'i |
769
+ | `s3.bucket` | `string \| null` | `null` | Bucket; `JSKELET_LOG_BUCKET` ezer |
770
+ | `s3.prefix` | `string` | `"jskelet/logs/"` | Nesne anahtarı öneki |
771
+ | `s3.region` | `string \| null` | `null` | Bölge; verilmezse `AWS_REGION` / `AWS_DEFAULT_REGION` |
772
+ | `s3.endpoint` | `string \| null` | `null` | MinIO vb. için path-style endpoint |
773
+ | `s3.flushIntervalMs` | `number` | `5000` | Batch flush aralığı |
774
+ | `s3.maxBatch` | `number` | `100` | Bu kadar satırda erken flush |
775
+
776
+ S3 credential'ları config'e yazılmaz: `AWS_ACCESS_KEY_ID`,
777
+ `AWS_SECRET_ACCESS_KEY`, isteğe bağlı `AWS_SESSION_TOKEN`. Bucket/region/
778
+ credential eksikse uyarı basılır ve S3 sink kapanır; site ayağa kalkmaya devam
779
+ eder. Framework `@aws-sdk` taşımaz — PutObject SigV4 ile gömülüdür.
780
+
781
+ ```js
782
+ logs: {
783
+ console: true,
784
+ kinds: ["http", "error"],
785
+ file: { enabled: true, dir: "logs" },
786
+ s3: {
787
+ enabled: process.env.NODE_ENV === "production",
788
+ bucket: process.env.JSKELET_LOG_BUCKET,
789
+ prefix: "my-app/logs/",
790
+ region: process.env.AWS_REGION,
791
+ },
792
+ }
793
+ ```
794
+
755
795
  ### `admin()`
756
796
 
757
797
  Framework yönetim paneli (`/_jskelet/admin`). Bellek içi / Redis / Cloudflare
@@ -933,6 +973,11 @@ basılmaz.
933
973
  | `JSKELET_SECRET` | `jskelet/cookies` | — | İmzalı cookie sırrı. `security.cookieSecret` verilmediğinde buradan okunur; ikisi de yoksa imzalı cookie API'si hata verir. [12](./12-panel-ve-oturum.md) |
934
974
  | `DEV_TOKEN` | `devGate`, `prewarm` | — | Ayarlıysa token taşımayan her isteğe 404 döner. Isıtma token'ı çerez olarak taşır. [09](./09-dev-araclari.md) |
935
975
  | `JSKELET_ADMIN` | `createApp` | — | Ayarlıysa yönetim panelini açar; `0` config'te açık olan paneli kapatır. Env config'i ezer, çünkü panel genelde bir arıza sırasında tek seferlik açılır. [06](./06-cache.md) |
976
+ | `JSKELET_LOG_BUCKET` | `logs.s3` | — | S3 log bucket'ı; `logs.s3.bucket`'ı ezer |
977
+ | `AWS_ACCESS_KEY_ID` | `logs.s3` | — | S3 PutObject imzası. Yoksa ve `s3.enabled` ise sink kapanır |
978
+ | `AWS_SECRET_ACCESS_KEY` | `logs.s3` | — | S3 imza sırrı |
979
+ | `AWS_SESSION_TOKEN` | `logs.s3` | — | Geçici credential'lar için isteğe bağlı |
980
+ | `AWS_REGION` / `AWS_DEFAULT_REGION` | `logs.s3` | — | `logs.s3.region` verilmezse buradan okunur |
936
981
  | `JSKELET_CLOUDFLARE_KEY` | Cloudflare cache yüzeyi | — | API token. Verilene kadar CDN purge'ü ve edge analitiği kapalıdır; config'teki `apiToken`'ı ezer. Token hiçbir cevapta dönmez. [06](./06-cache.md) |
937
982
  | `JSKELET_CLOUDFLARE_ZONE_ID` | Cloudflare cache yüzeyi | — | Zone kimliği. Token'la birlikte verilmedikçe hiçbir Cloudflare ucu çağrılmaz |
938
983
  | `JSKELET_CLOUDFLARE_HOSTNAME` | Cloudflare cache yüzeyi | — | Purge URL'lerinin kökü. Panel iç bir adresten açılıyorsa gerekir |
@@ -47,6 +47,11 @@ ayarlamayı düşünmeniz gerekenler:
47
47
  | `PREWARM_MAX` | Site boyutuna göre | Açılışta ısıtılacak sayfa sayısı |
48
48
  | `PREWARM_INTERVAL_SECONDS` | `0` ya da uzun bir değer | Hiç ziyaret edilmeyen sayfaları sıcak tutmak isterseniz |
49
49
  | `DEV_TOKEN` | Yalnızca staging'de | Yayına açılmamış ortamı gizler |
50
+ | `JSKELET_LOG_BUCKET` + `AWS_*` | Access log'u S3'e yazıyorsanız | `logs.s3` credential ve bucket; ayrıntı [07](./07-yapilandirma.md) |
51
+
52
+ Production'da dosya veya S3 sink açıldığında HTTP access log middleware
53
+ otomatik mount edilir (`logs.kinds` içinde `http` varsa). Admin paneli ring'i
54
+ ayrıdır — disk/S3'e yazılan satırlar panele akmaz.
50
55
 
51
56
  Tam liste ve prewarm ayarlarının öncelik sırası:
52
57
  [07-yapilandirma.md](./07-yapilandirma.md).
@@ -768,6 +768,46 @@ redis: {
768
768
  }
769
769
  ```
770
770
 
771
+ ### `logs`
772
+
773
+ Persistent log sinks. Everything is off by default: stdout and the admin panel
774
+ ring keep their current behaviour. When enabled, HTTP access logs and framework
775
+ events (`event` / `error`) are written as NDJSON lines to a file and/or S3.
776
+
777
+ | Field | Type | Default | Meaning |
778
+ | --- | --- | --- | --- |
779
+ | `console` | `boolean` | `true` | Whether runtime `http` / `event` / `error` lines go to stdout (banner/build lines are unaffected) |
780
+ | `kinds` | `("http" \| "event" \| "error")[]` | all | Which kinds reach the sinks |
781
+ | `file.enabled` | `boolean` | `false` | Daily file sink |
782
+ | `file.dir` | `string` | `"logs"` | Directory relative to the project root; `jskelet-YYYY-MM-DD.log` |
783
+ | `file.rotate` | `"daily"` | `"daily"` | Daily rotation only |
784
+ | `s3.enabled` | `boolean` | `false` | S3 batch PutObject sink |
785
+ | `s3.bucket` | `string \| null` | `null` | Bucket; `JSKELET_LOG_BUCKET` overrides |
786
+ | `s3.prefix` | `string` | `"jskelet/logs/"` | Object key prefix |
787
+ | `s3.region` | `string \| null` | `null` | Region; falls back to `AWS_REGION` / `AWS_DEFAULT_REGION` when unset |
788
+ | `s3.endpoint` | `string \| null` | `null` | Path-style endpoint for MinIO etc. |
789
+ | `s3.flushIntervalMs` | `number` | `5000` | Batch flush interval |
790
+ | `s3.maxBatch` | `number` | `100` | Flush early after this many lines |
791
+
792
+ S3 credentials are not written in the config: `AWS_ACCESS_KEY_ID`,
793
+ `AWS_SECRET_ACCESS_KEY`, optional `AWS_SESSION_TOKEN`. Missing bucket/region/
794
+ credentials warn and disable the S3 sink; the site still starts. The framework
795
+ does not ship `@aws-sdk` — PutObject is embedded with SigV4.
796
+
797
+ ```js
798
+ logs: {
799
+ console: true,
800
+ kinds: ["http", "error"],
801
+ file: { enabled: true, dir: "logs" },
802
+ s3: {
803
+ enabled: process.env.NODE_ENV === "production",
804
+ bucket: process.env.JSKELET_LOG_BUCKET,
805
+ prefix: "my-app/logs/",
806
+ region: process.env.AWS_REGION,
807
+ },
808
+ }
809
+ ```
810
+
771
811
  ### `admin()`
772
812
 
773
813
  The framework admin panel (`/_jskelet/admin`). It manages the in-process /
@@ -954,6 +994,11 @@ and no warning is printed.
954
994
  | `JSKELET_SECRET` | `jskelet/cookies` | — | The signed cookie secret. Read when `security.cookieSecret` is not set; if neither exists, the signed cookie API throws. [12](./12-dashboards-and-sessions.md) |
955
995
  | `DEV_TOKEN` | `devGate`, `prewarm` | — | If set, every request without a token gets a 404. Prewarming carries the token as a cookie. [09](./09-dev-tools.md) |
956
996
  | `JSKELET_ADMIN` | `createApp` | — | When set, turns the admin panel on; `0` turns off a panel enabled in the config. The env wins because the panel is usually opened once during an incident. [06](./06-caching.md) |
997
+ | `JSKELET_LOG_BUCKET` | `logs.s3` | — | S3 log bucket; overrides `logs.s3.bucket` |
998
+ | `AWS_ACCESS_KEY_ID` | `logs.s3` | — | Signs S3 PutObject. Missing when `s3.enabled` disables the sink |
999
+ | `AWS_SECRET_ACCESS_KEY` | `logs.s3` | — | S3 signing secret |
1000
+ | `AWS_SESSION_TOKEN` | `logs.s3` | — | Optional, for temporary credentials |
1001
+ | `AWS_REGION` / `AWS_DEFAULT_REGION` | `logs.s3` | — | Read when `logs.s3.region` is unset |
957
1002
  | `JSKELET_CLOUDFLARE_KEY` | Cloudflare cache surface | — | API token. Until it is set, CDN purging and edge analytics stay off; it overrides `apiToken` in the config. The token is never returned in a response. [06](./06-caching.md) |
958
1003
  | `JSKELET_CLOUDFLARE_ZONE_ID` | Cloudflare cache surface | — | Zone identifier. No Cloudflare endpoint is called unless it is set alongside the token |
959
1004
  | `JSKELET_CLOUDFLARE_HOSTNAME` | Cloudflare cache surface | — | The root for purge URLs. Required when the panel is opened over an internal address |
@@ -48,6 +48,11 @@ considering in production:
48
48
  | `PREWARM_MAX` | Depends on site size | Number of pages warmed at startup |
49
49
  | `PREWARM_INTERVAL_SECONDS` | `0` or a long value | If you want to keep never-visited pages warm |
50
50
  | `DEV_TOKEN` | Staging only | Hides an environment that is not public yet |
51
+ | `JSKELET_LOG_BUCKET` + `AWS_*` | If you write access logs to S3 | `logs.s3` credentials and bucket; details in [07](./07-configuration.md) |
52
+
53
+ When a file or S3 sink is enabled in production, the HTTP access log middleware
54
+ mounts automatically (if `http` is in `logs.kinds`). The admin panel ring is
55
+ separate — lines written to disk/S3 do not stream into the panel.
51
56
 
52
57
  The full list and the precedence order of the prewarm settings:
53
58
  [07-configuration.md](./07-configuration.md).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jskelet",
3
- "version": "0.3.0",
3
+ "version": "0.3.2",
4
4
  "description": "A framework that feels like no framework: Express 5 + EJS server rendering, vanilla JS islands, Tailwind v4 and an in-process HTML TTL cache.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -182,12 +182,16 @@ export const MESSAGES = {
182
182
 
183
183
  /* --- host --- */
184
184
  "host.section": "Host",
185
+ "host.cpu": "CPU",
185
186
  "host.memory": "Memory",
186
187
  "host.disk": "Disk",
187
188
  "host.noStats": "This platform does not report filesystem stats.",
188
- "host.ramShared": "This process: {rss} RSS.",
189
- "host.ramOnly": "This process holds the whole cache: {rss} RSS, {html} of it HTML.",
190
- "host.diskNote": "{free} free on {path} — build output and logs live here.",
189
+ "host.cpuNote": "This process across {cores} cores (since last refresh).",
190
+ "host.ramShared": "This process: {rss} RSS · {heap} heap.",
191
+ "host.ramOnly":
192
+ "This process holds the whole cache: {rss} RSS · {heap} heap, {html} of it HTML.",
193
+ "host.diskNote":
194
+ "Project files on {path} (excluding node_modules) · {free} free on the volume.",
191
195
 
192
196
  /* --- işlemler --- */
193
197
  "actions.section": "Actions",
@@ -479,12 +483,16 @@ export const MESSAGES = {
479
483
  "cf.rowPurgeTitle": "Bu URL'i Cloudflare'de düşür",
480
484
 
481
485
  "host.section": "Makine",
486
+ "host.cpu": "CPU",
482
487
  "host.memory": "Bellek",
483
488
  "host.disk": "Disk",
484
489
  "host.noStats": "Bu platform dosya sistemi bilgisi vermiyor.",
485
- "host.ramShared": "Bu süreç: {rss} RSS.",
486
- "host.ramOnly": "Önbelleğin tamamı bu süreçte: {rss} RSS, {html} kadarı HTML.",
487
- "host.diskNote": "{path} üzerinde {free} boş — build çıktısı ve loglar burada.",
490
+ "host.cpuNote": "Bu sürecin {cores} çekirdek üzerindeki payı (son yenilemeden beri).",
491
+ "host.ramShared": "Bu süreç: {rss} RSS · {heap} heap.",
492
+ "host.ramOnly":
493
+ "Önbelleğin tamamı bu süreçte: {rss} RSS · {heap} heap, {html} kadarı HTML.",
494
+ "host.diskNote":
495
+ "{path} üzerindeki proje dosyaları (node_modules hariç) · birimde {free} boş.",
488
496
 
489
497
  "actions.section": "İşlemler",
490
498
  "actions.target.placeholder": "/haber/:slug · /haber/bir-yazi · invalidation hedefi",
@@ -330,9 +330,14 @@ footer {
330
330
  }
331
331
 
332
332
  main {
333
- max-width: 1180px;
333
+ width: 100%;
334
+ max-width: 1600px;
334
335
  margin: 0 auto;
335
- padding: 20px;
336
+ padding: 20px 24px 28px;
337
+ }
338
+
339
+ .page {
340
+ width: 100%;
336
341
  }
337
342
 
338
343
  .cards {
@@ -437,6 +437,15 @@ JSKELET_CLOUDFLARE_ZONE_ID=your_zone_id</code></pre>
437
437
  <span class="count" id="host-heading">—</span>
438
438
  </h2>
439
439
  <div class="body">
440
+ <div class="meter">
441
+ <div class="meter-head">
442
+ <span data-i18n="host.cpu">CPU</span>
443
+ <span class="mono" id="cpu-text">—</span>
444
+ </div>
445
+ <div class="bar"><span id="cpu-bar"></span></div>
446
+ <p class="hint m0" id="cpu-note">—</p>
447
+ </div>
448
+
440
449
  <div class="meter">
441
450
  <div class="meter-head">
442
451
  <span data-i18n="host.memory">Memory</span>
@@ -591,9 +591,8 @@ function renderRedis(redis, state) {
591
591
  }
592
592
 
593
593
  /**
594
- * Makinenin RAM ve disk durumu. Paylaşımlı kademe kapalıyken önbelleğin
595
- * tamamı bu sürecin belleğinde yaşıyor; `maxEntries` ile RAM arasındaki
596
- * ilişkiyi görmeden ayar yapmak körlemesine oluyor.
594
+ * System sayfası: çubuklar bu sürecin makine kapasitesindeki payı
595
+ * (CPU / RSS / proje diski), host'un genel doluluğu değil.
597
596
  *
598
597
  * @param {any} host
599
598
  * @param {any} proc
@@ -608,14 +607,24 @@ function renderHost(host, proc, redis) {
608
607
  .filter(Boolean)
609
608
  .join(" · ");
610
609
 
611
- const ramUsed = host.memory.used / host.memory.total;
610
+ // Çubuklar makine doluluğunu değil, **bu sürecin** payını gösterir.
611
+ const cpuPercent = Number(proc.cpuPercent) || 0;
612
+ $("cpu-text").textContent = `${cpuPercent.toFixed(1)}%`;
613
+ meter("cpu-bar", cpuPercent / 100);
614
+ $("cpu-note").textContent = t("host.cpuNote", { cores: host.cpus });
615
+
616
+ const ramRatio = host.memory.total > 0 ? proc.memory.rss / host.memory.total : 0;
612
617
  $("ram-text").textContent =
613
- `${formatBytes(host.memory.used)} / ${formatBytes(host.memory.total)}`;
614
- meter("ram-bar", ramUsed);
618
+ `${formatBytes(proc.memory.rss)} / ${formatBytes(host.memory.total)}`;
619
+ meter("ram-bar", ramRatio);
615
620
  $("ram-note").textContent = redis.connected
616
- ? t("host.ramShared", { rss: formatBytes(proc.memory.rss) })
621
+ ? t("host.ramShared", {
622
+ rss: formatBytes(proc.memory.rss),
623
+ heap: formatBytes(proc.memory.heapUsed),
624
+ })
617
625
  : t("host.ramOnly", {
618
626
  rss: formatBytes(proc.memory.rss),
627
+ heap: formatBytes(proc.memory.heapUsed),
619
628
  html: formatBytes(latest.html.bytes),
620
629
  });
621
630
 
@@ -626,11 +635,11 @@ function renderHost(host, proc, redis) {
626
635
  return;
627
636
  }
628
637
 
629
- const diskUsed = (host.disk.total - host.disk.free) / host.disk.total;
638
+ const appDisk = Number(proc.diskBytes) || 0;
639
+ const diskRatio = host.disk.total > 0 ? appDisk / host.disk.total : 0;
630
640
  $("disk-text").textContent =
631
- `${formatBytes(host.disk.total - host.disk.free)} / ${formatBytes(host.disk.total)}`;
632
- meter("disk-bar", diskUsed);
633
- // Önbellek diske yazılmıyor; disk build çıktısı ve log için önemli.
641
+ `${formatBytes(appDisk)} / ${formatBytes(host.disk.total)}`;
642
+ meter("disk-bar", diskRatio);
634
643
  $("disk-note").textContent = t("host.diskNote", {
635
644
  free: formatBytes(host.disk.free),
636
645
  path: host.disk.path,
@@ -191,6 +191,44 @@ export const DEFAULT_REDIS = {
191
191
  commandTimeoutMs: 200,
192
192
  };
193
193
 
194
+ /**
195
+ * Kalıcı log sink'leri (dosya + S3).
196
+ *
197
+ * Varsayılan her şey kapalı: stdout ve admin ring mevcut davranışını korur.
198
+ * `kinds` hangi structured kayıtların sink'lere gideceğini seçer; `console`
199
+ * runtime `http`/`event`/`error` satırlarının terminalde görünmesini kontrol
200
+ * eder (banner/build satırlarına dokunmaz).
201
+ *
202
+ * S3 credential'ları config'e yazılmaz — `AWS_ACCESS_KEY_ID` /
203
+ * `AWS_SECRET_ACCESS_KEY` (ve isteğe bağlı `AWS_SESSION_TOKEN`). Bucket için
204
+ * `JSKELET_LOG_BUCKET` env'i config'i ezer.
205
+ */
206
+ export const DEFAULT_LOGS = {
207
+ console: true,
208
+ /** @type {Array<"http" | "event" | "error">} */
209
+ kinds: ["http", "event", "error"],
210
+ file: {
211
+ enabled: false,
212
+ /** Proje köküne göre relative. */
213
+ dir: "logs",
214
+ /** Yalnızca günlük rotasyon. */
215
+ rotate: /** @type {"daily"} */ ("daily"),
216
+ },
217
+ s3: {
218
+ enabled: false,
219
+ /** @type {string | null} */
220
+ bucket: null,
221
+ prefix: "jskelet/logs/",
222
+ /** @type {string | null} */
223
+ region: null,
224
+ /** MinIO vb. için; null → AWS. */
225
+ /** @type {string | null} */
226
+ endpoint: null,
227
+ flushIntervalMs: 5000,
228
+ maxBatch: 100,
229
+ },
230
+ };
231
+
194
232
  /**
195
233
  * Framework yönetim paneli (`/_jskelet/admin`).
196
234
  *
@@ -19,9 +19,10 @@
19
19
  * query?: { [source]: string[] | true }, maxEntries?: number,
20
20
  * data?: {...}, redis?: {...}, prewarm?: {...} }
21
21
  * admin() → { enabled?, basePath?, allowIps?, blockBots?, … }
22
+ * logs → { console?, kinds?, file?, s3? }
22
23
  *
23
24
  * Fonksiyon olmayan bölümler (`brand`, `security`, `static`, `navigation`…)
24
- * düz nesne olarak okunur.
25
+ * düz nesne olarak okunur. `logs` fonksiyon ya da düz nesne olabilir.
25
26
  */
26
27
  import fs from "node:fs";
27
28
  import path from "node:path";
@@ -36,6 +37,7 @@ import {
36
37
  DEFAULT_DEV_GATE_BYPASS,
37
38
  DEFAULT_DIRS,
38
39
  DEFAULT_HTML_CACHE_MAX_ENTRIES,
40
+ DEFAULT_LOGS,
39
41
  DEFAULT_NAVIGATION,
40
42
  DEFAULT_NAVIGATION_EXCLUDE,
41
43
  DEFAULT_PREWARM,
@@ -75,6 +77,19 @@ const CONFIG_FILE = "jskelet.config.mjs";
75
77
  * @property {number} commandTimeoutMs
76
78
  */
77
79
 
80
+ /**
81
+ * @typedef {"http" | "event" | "error"} LogKind
82
+ *
83
+ * @typedef {object} LogsConfig
84
+ * @property {boolean} console Runtime http/event/error satırları stdout'a
85
+ * basılsın mı (banner/build satırları etkilenmez).
86
+ * @property {LogKind[]} kinds Sink'lere giden kayıt türleri.
87
+ * @property {{ enabled: boolean, dir: string, rotate: "daily" }} file
88
+ * @property {{ enabled: boolean, bucket: string | null, prefix: string,
89
+ * region: string | null, endpoint: string | null, flushIntervalMs: number,
90
+ * maxBatch: number }} s3
91
+ */
92
+
78
93
  /**
79
94
  * @typedef {import('./pattern.js').CompiledPattern} CompiledPattern
80
95
  *
@@ -96,6 +111,7 @@ const CONFIG_FILE = "jskelet.config.mjs";
96
111
  * @property {{ attempts: number, delayMs: number }} transientRetry
97
112
  * @property {RedisConfig} redis Opsiyonel Redis ikinci kademesi.
98
113
  * @property {typeof DEFAULT_UPSTREAM_LIMIT} upstream Upstream hız freni.
114
+ * @property {LogsConfig} logs Kalıcı log sink'leri (dosya + S3).
99
115
  * @property {typeof DEFAULT_ADMIN} admin Framework yönetim paneli.
100
116
  * @property {typeof DEFAULT_CLOUDFLARE} cloudflare Cloudflare cache yüzeyi.
101
117
  * @property {Record<string, unknown>} prewarm
@@ -384,6 +400,77 @@ function normalizeCloudflare(raw) {
384
400
  };
385
401
  }
386
402
 
403
+ const LOG_KINDS = new Set(["http", "event", "error"]);
404
+
405
+ /**
406
+ * Kalıcı log sink'leri. Bozuk bir `kinds` listesi siteyi düşürmemeli —
407
+ * bilinmeyen girdiler atılır; hiç geçerli tür kalmazsa varsayılana dönülür.
408
+ *
409
+ * @param {unknown} raw
410
+ * @returns {LogsConfig}
411
+ */
412
+ export function normalizeLogs(raw) {
413
+ const source = /** @type {Record<string, any>} */ (raw ?? {});
414
+ const fileRaw = /** @type {Record<string, any>} */ (source.file ?? {});
415
+ const s3Raw = /** @type {Record<string, any>} */ (source.s3 ?? {});
416
+
417
+ /** @type {LogKind[]} */
418
+ let kinds = DEFAULT_LOGS.kinds;
419
+ if (Array.isArray(source.kinds)) {
420
+ const filtered = source.kinds.filter(
421
+ (entry) => typeof entry === "string" && LOG_KINDS.has(entry),
422
+ );
423
+ if (filtered.length) kinds = /** @type {LogKind[]} */ ([...new Set(filtered)]);
424
+ else {
425
+ console.warn(
426
+ "[config] logs.kinds has no valid entries (http|event|error), using defaults",
427
+ );
428
+ }
429
+ } else if (source.kinds != null) {
430
+ console.warn("[config] logs.kinds must be an array, using defaults");
431
+ }
432
+
433
+ const flush = Number(s3Raw.flushIntervalMs);
434
+ const batch = Number(s3Raw.maxBatch);
435
+
436
+ /** @param {unknown} value */
437
+ const text = (value) => (typeof value === "string" && value ? value : null);
438
+
439
+ const envBucket = process.env.JSKELET_LOG_BUCKET;
440
+ const envRegion = process.env.AWS_REGION || process.env.AWS_DEFAULT_REGION;
441
+
442
+ return {
443
+ console: source.console !== false,
444
+ kinds,
445
+ file: {
446
+ enabled: fileRaw.enabled === true,
447
+ dir:
448
+ typeof fileRaw.dir === "string" && fileRaw.dir.trim()
449
+ ? fileRaw.dir.trim()
450
+ : DEFAULT_LOGS.file.dir,
451
+ rotate: "daily",
452
+ },
453
+ s3: {
454
+ enabled: s3Raw.enabled === true,
455
+ bucket: text(envBucket) ?? text(s3Raw.bucket),
456
+ prefix:
457
+ typeof s3Raw.prefix === "string" && s3Raw.prefix
458
+ ? s3Raw.prefix
459
+ : DEFAULT_LOGS.s3.prefix,
460
+ region: text(s3Raw.region) ?? text(envRegion),
461
+ endpoint: text(s3Raw.endpoint),
462
+ flushIntervalMs:
463
+ Number.isFinite(flush) && flush >= 500
464
+ ? Math.min(60_000, Math.floor(flush))
465
+ : DEFAULT_LOGS.s3.flushIntervalMs,
466
+ maxBatch:
467
+ Number.isFinite(batch) && batch >= 1
468
+ ? Math.min(5000, Math.floor(batch))
469
+ : DEFAULT_LOGS.s3.maxBatch,
470
+ },
471
+ };
472
+ }
473
+
387
474
  /**
388
475
  * `cache().query` → yol deseni başına, cache anahtarına girmesine izin verilen
389
476
  * query parametreleri.
@@ -680,12 +767,13 @@ export async function loadConfig(options = {}) {
680
767
  }
681
768
  };
682
769
 
683
- const [headers, redirects, rewrites, cache, admin] = await Promise.all([
770
+ const [headers, redirects, rewrites, cache, admin, logs] = await Promise.all([
684
771
  section("headers"),
685
772
  section("redirects"),
686
773
  section("rewrites"),
687
774
  section("cache"),
688
775
  section("admin"),
776
+ section("logs"),
689
777
  ]);
690
778
 
691
779
  const {
@@ -721,6 +809,7 @@ export async function loadConfig(options = {}) {
721
809
  transientRetry,
722
810
  redis,
723
811
  upstream,
812
+ logs: normalizeLogs(logs),
724
813
  admin: normalizeAdmin(admin),
725
814
  cloudflare,
726
815
  prewarm,
package/src/log.mjs CHANGED
@@ -18,6 +18,28 @@ const isTTY = Boolean(stream.isTTY);
18
18
  const useColor =
19
19
  !process.env.NO_COLOR && (isTTY || process.env.JSKELET_COLOR === "1");
20
20
 
21
+ /**
22
+ * Runtime olaylarının (`http` / `event` / `errorBox`) stdout'a basılıp
23
+ * basılmayacağı. Banner ve build satırları etkilenmez — onlar başlangıç
24
+ * çıktısıdır, access log değil.
25
+ */
26
+ let consoleEnabled = true;
27
+
28
+ /**
29
+ * `http` kayıtlarının `emitLog` ile abonelere gidip gitmeyeceği. Access
30
+ * middleware mount edildiğinde kapatılır: aksi hâlde devtools + middleware
31
+ * aynı isteği iki kez sink'e yazar.
32
+ */
33
+ let emitHttp = true;
34
+
35
+ /**
36
+ * @param {{ console?: boolean, emitHttp?: boolean }} [options]
37
+ */
38
+ export function configureLog(options = {}) {
39
+ if (options.console !== undefined) consoleEnabled = options.console !== false;
40
+ if (options.emitHttp !== undefined) emitHttp = options.emitHttp !== false;
41
+ }
42
+
21
43
  /**
22
44
  * @param {string} open
23
45
  * @returns {(text: string) => string}
@@ -245,14 +267,16 @@ export function ready({ elapsed, url, watching, label = "Ready" }) {
245
267
  * @param {{ symbol?: string, scope: string, message: string, note?: string, time?: number | null }} info
246
268
  */
247
269
  export function event({ symbol = symbols.ok, scope, message, note, time = null }) {
248
- const body = `${message}${note ? ` ${note}` : ""}`;
249
- const padding = " ".repeat(Math.max(1, DETAIL - body.length));
270
+ if (consoleEnabled) {
271
+ const body = `${message}${note ? ` ${note}` : ""}`;
272
+ const padding = " ".repeat(Math.max(1, DETAIL - body.length));
250
273
 
251
- write(
252
- `${c.gray(clock())} ${symbol} ${c.bold(scope.padEnd(10))} ` +
253
- `${message}${note ? ` ${c.dim(note)}` : ""}${padding}` +
254
- `${c.gray((time == null ? "" : ms(time)).padStart(TIME))}\n`,
255
- );
274
+ write(
275
+ `${c.gray(clock())} ${symbol} ${c.bold(scope.padEnd(10))} ` +
276
+ `${message}${note ? ` ${c.dim(note)}` : ""}${padding}` +
277
+ `${c.gray((time == null ? "" : ms(time)).padStart(TIME))}\n`,
278
+ );
279
+ }
256
280
 
257
281
  emitLog({
258
282
  kind: "event",
@@ -270,24 +294,28 @@ export function event({ symbol = symbols.ok, scope, message, note, time = null }
270
294
  * @param {{ method: string, url: string, status: number, ms: number, cache?: string | null }} info
271
295
  */
272
296
  export function http(info) {
273
- const tint =
274
- info.status >= 500 ? c.red : info.status >= 400 ? c.yellow : c.green;
275
-
276
- write(
277
- `${c.gray(clock())} ${c.bold(info.method.padEnd(6))}` +
278
- `${c.gray(truncate(info.url, 28).padEnd(30))}` +
279
- `${tint(String(info.status))} ${c.gray(ms(info.ms).padStart(TIME))}` +
280
- `${info.cache === "HIT" ? ` ${c.dim("cached")}` : ""}\n`,
281
- );
297
+ if (consoleEnabled) {
298
+ const tint =
299
+ info.status >= 500 ? c.red : info.status >= 400 ? c.yellow : c.green;
282
300
 
283
- emitLog({
284
- kind: "http",
285
- method: info.method,
286
- url: info.url,
287
- status: info.status,
288
- ms: info.ms,
289
- cache: info.cache ?? null,
290
- });
301
+ write(
302
+ `${c.gray(clock())} ${c.bold(info.method.padEnd(6))}` +
303
+ `${c.gray(truncate(info.url, 28).padEnd(30))}` +
304
+ `${tint(String(info.status))} ${c.gray(ms(info.ms).padStart(TIME))}` +
305
+ `${info.cache === "HIT" ? ` ${c.dim("cached")}` : ""}\n`,
306
+ );
307
+ }
308
+
309
+ if (emitHttp) {
310
+ emitLog({
311
+ kind: "http",
312
+ method: info.method,
313
+ url: info.url,
314
+ status: info.status,
315
+ ms: info.ms,
316
+ cache: info.cache ?? null,
317
+ });
318
+ }
291
319
  }
292
320
 
293
321
  /**
@@ -333,27 +361,29 @@ export function box({ title, lines, tint = c.cyan }) {
333
361
  * @param {{ title: string, name: string, message: string, lines?: string[] }} info
334
362
  */
335
363
  export function errorBox({ title, name, message, lines = [] }) {
336
- const top = `┌─ ${title} ${"─".repeat(Math.max(0, BOX - title.length - 3))}┐`;
337
- const bottom = `└${"─".repeat(BOX)}┘`;
338
-
339
- /** @param {string} text @param {(value: string) => string} [tint] */
340
- const line = (text, tint = (value) => value) => {
341
- const clipped = truncate(text, BOX - 4);
342
- write(
343
- `${c.red("│")} ${tint(clipped)}${" ".repeat(BOX - 4 - clipped.length)} ${c.red("│")}\n`,
344
- );
345
- };
346
-
347
- write(`\n${c.red(top)}\n`);
348
- line("");
349
- line(name, (value) => c.bold(c.red(value)));
350
- for (const part of wrap(message, BOX - 4)) line(part);
351
- if (lines.length) {
364
+ if (consoleEnabled) {
365
+ const top = `┌─ ${title} ${"─".repeat(Math.max(0, BOX - title.length - 3))}┐`;
366
+ const bottom = `└${"─".repeat(BOX)}┘`;
367
+
368
+ /** @param {string} text @param {(value: string) => string} [tint] */
369
+ const line = (text, tint = (value) => value) => {
370
+ const clipped = truncate(text, BOX - 4);
371
+ write(
372
+ `${c.red("│")} ${tint(clipped)}${" ".repeat(BOX - 4 - clipped.length)} ${c.red("│")}\n`,
373
+ );
374
+ };
375
+
376
+ write(`\n${c.red(top)}\n`);
377
+ line("");
378
+ line(name, (value) => c.bold(c.red(value)));
379
+ for (const part of wrap(message, BOX - 4)) line(part);
380
+ if (lines.length) {
381
+ line("");
382
+ for (const part of lines) line(part, c.gray);
383
+ }
352
384
  line("");
353
- for (const part of lines) line(part, c.gray);
385
+ write(`${c.red(bottom)}\n\n`);
354
386
  }
355
- line("");
356
- write(`${c.red(bottom)}\n\n`);
357
387
 
358
388
  emitLog({
359
389
  kind: "error",