jskelet 0.3.1 → 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
@@ -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.1",
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",
@@ -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",
@@ -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
+ }
@@ -0,0 +1,214 @@
1
+ /**
2
+ * Minimal S3 PutObject — yalnızca SigV4 + `fetch`.
3
+ *
4
+ * `@aws-sdk/client-s3` taşınmıyor: tek operasyon için SDK'nın maliyeti ve
5
+ * peer yüzeyi gereksiz. Credential zinciri env'den okunur
6
+ * (`AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY` / `AWS_SESSION_TOKEN`);
7
+ * IAM role / instance metadata yok — container'a key vermeyen kurulumlar
8
+ * için sink zaten açılışta no-op'a düşer.
9
+ */
10
+ import crypto from "node:crypto";
11
+
12
+ /**
13
+ * @typedef {{ accessKeyId: string, secretAccessKey: string,
14
+ * sessionToken?: string | null }} AwsCredentials
15
+ *
16
+ * @typedef {{ bucket: string, key: string, body: string | Buffer,
17
+ * region: string, endpoint?: string | null, credentials: AwsCredentials,
18
+ * now?: Date }} PutObjectInput
19
+ */
20
+
21
+ /**
22
+ * @param {string} algorithm
23
+ * @param {string | Buffer} key
24
+ * @param {string | Buffer} data
25
+ * @returns {Buffer}
26
+ */
27
+ function hmac(algorithm, key, data) {
28
+ return crypto.createHmac(algorithm, key).update(data).digest();
29
+ }
30
+
31
+ /**
32
+ * @param {string | Buffer} data
33
+ * @returns {string}
34
+ */
35
+ export function sha256Hex(data) {
36
+ return crypto.createHash("sha256").update(data).digest("hex");
37
+ }
38
+
39
+ /**
40
+ * @param {Date} date
41
+ * @returns {{ amzDate: string, dateStamp: string }}
42
+ */
43
+ export function formatAmzDates(date) {
44
+ const iso = date.toISOString().replace(/[:-]|\.\d{3}/g, "");
45
+ return {
46
+ amzDate: iso,
47
+ dateStamp: iso.slice(0, 8),
48
+ };
49
+ }
50
+
51
+ /**
52
+ * @param {string} secretAccessKey
53
+ * @param {string} dateStamp
54
+ * @param {string} region
55
+ * @param {string} service
56
+ * @returns {Buffer}
57
+ */
58
+ export function deriveSigningKey(secretAccessKey, dateStamp, region, service) {
59
+ const kDate = hmac("sha256", `AWS4${secretAccessKey}`, dateStamp);
60
+ const kRegion = hmac("sha256", kDate, region);
61
+ const kService = hmac("sha256", kRegion, service);
62
+ return hmac("sha256", kService, "aws4_request");
63
+ }
64
+
65
+ /**
66
+ * @param {{ method: string, canonicalUri: string, canonicalQuerystring?: string,
67
+ * headers: Record<string, string>, payloadHash: string }} parts
68
+ * @returns {{ canonicalRequest: string, signedHeaders: string }}
69
+ */
70
+ export function buildCanonicalRequest(parts) {
71
+ const names = Object.keys(parts.headers)
72
+ .map((name) => name.toLowerCase())
73
+ .sort();
74
+ const canonicalHeaders = names
75
+ .map((name) => `${name}:${parts.headers[name].trim().replace(/\s+/g, " ")}\n`)
76
+ .join("");
77
+ const signedHeaders = names.join(";");
78
+
79
+ const canonicalRequest = [
80
+ parts.method,
81
+ parts.canonicalUri,
82
+ parts.canonicalQuerystring ?? "",
83
+ canonicalHeaders,
84
+ signedHeaders,
85
+ parts.payloadHash,
86
+ ].join("\n");
87
+
88
+ return { canonicalRequest, signedHeaders };
89
+ }
90
+
91
+ /**
92
+ * @param {{ amzDate: string, dateStamp: string, region: string, service: string,
93
+ * canonicalRequest: string }} parts
94
+ * @returns {string}
95
+ */
96
+ export function buildStringToSign(parts) {
97
+ const scope = `${parts.dateStamp}/${parts.region}/${parts.service}/aws4_request`;
98
+ return [
99
+ "AWS4-HMAC-SHA256",
100
+ parts.amzDate,
101
+ scope,
102
+ sha256Hex(parts.canonicalRequest),
103
+ ].join("\n");
104
+ }
105
+
106
+ /**
107
+ * URI path encode (S3 key segment'leri). `/` korunur.
108
+ *
109
+ * @param {string} value
110
+ * @returns {string}
111
+ */
112
+ export function encodeS3Path(value) {
113
+ return value
114
+ .split("/")
115
+ .map((segment) => encodeURIComponent(segment).replace(/[!'()*]/g, (char) =>
116
+ `%${char.charCodeAt(0).toString(16).toUpperCase()}`,
117
+ ))
118
+ .join("/");
119
+ }
120
+
121
+ /**
122
+ * PutObject isteğini imzalar; ağ çağrısı yapmaz — test edilebilir olsun diye.
123
+ *
124
+ * @param {PutObjectInput} input
125
+ * @returns {{ url: string, headers: Record<string, string>, body: Buffer }}
126
+ */
127
+ export function buildSignedPutObject(input) {
128
+ const body = Buffer.isBuffer(input.body) ? input.body : Buffer.from(input.body);
129
+ const now = input.now ?? new Date();
130
+ const { amzDate, dateStamp } = formatAmzDates(now);
131
+ const service = "s3";
132
+ const payloadHash = sha256Hex(body);
133
+
134
+ const keyPath = encodeS3Path(input.key.replace(/^\/+/, ""));
135
+ const usePathStyle = Boolean(input.endpoint);
136
+ const endpointHost = input.endpoint
137
+ ? new URL(input.endpoint).host
138
+ : `${input.bucket}.s3.${input.region}.amazonaws.com`;
139
+ const host = usePathStyle ? new URL(input.endpoint ?? "").host : endpointHost;
140
+
141
+ const canonicalUri = usePathStyle
142
+ ? `/${encodeS3Path(input.bucket)}/${keyPath}`
143
+ : `/${keyPath}`;
144
+
145
+ /** @type {Record<string, string>} */
146
+ const headers = {
147
+ host,
148
+ "content-type": "application/x-ndjson",
149
+ "content-length": String(body.length),
150
+ "x-amz-content-sha256": payloadHash,
151
+ "x-amz-date": amzDate,
152
+ };
153
+
154
+ if (input.credentials.sessionToken) {
155
+ headers["x-amz-security-token"] = input.credentials.sessionToken;
156
+ }
157
+
158
+ const { canonicalRequest, signedHeaders } = buildCanonicalRequest({
159
+ method: "PUT",
160
+ canonicalUri,
161
+ headers,
162
+ payloadHash,
163
+ });
164
+
165
+ const stringToSign = buildStringToSign({
166
+ amzDate,
167
+ dateStamp,
168
+ region: input.region,
169
+ service,
170
+ canonicalRequest,
171
+ });
172
+
173
+ const signingKey = deriveSigningKey(
174
+ input.credentials.secretAccessKey,
175
+ dateStamp,
176
+ input.region,
177
+ service,
178
+ );
179
+ const signature = hmac("sha256", signingKey, stringToSign).toString("hex");
180
+ const credentialScope = `${dateStamp}/${input.region}/${service}/aws4_request`;
181
+
182
+ headers.authorization =
183
+ `AWS4-HMAC-SHA256 Credential=${input.credentials.accessKeyId}/${credentialScope}, ` +
184
+ `SignedHeaders=${signedHeaders}, Signature=${signature}`;
185
+
186
+ const base = usePathStyle
187
+ ? String(input.endpoint).replace(/\/+$/, "")
188
+ : `https://${host}`;
189
+ const url = usePathStyle
190
+ ? `${base}/${encodeS3Path(input.bucket)}/${keyPath}`
191
+ : `${base}/${keyPath}`;
192
+
193
+ return { url, headers, body };
194
+ }
195
+
196
+ /**
197
+ * @param {PutObjectInput} input
198
+ * @returns {Promise<void>}
199
+ */
200
+ export async function putObject(input) {
201
+ const signed = buildSignedPutObject(input);
202
+ const response = await fetch(signed.url, {
203
+ method: "PUT",
204
+ headers: signed.headers,
205
+ body: signed.body,
206
+ });
207
+
208
+ if (!response.ok) {
209
+ const text = await response.text().catch(() => "");
210
+ throw new Error(
211
+ `S3 PutObject ${response.status}: ${text.slice(0, 200) || response.statusText}`,
212
+ );
213
+ }
214
+ }
@@ -0,0 +1,112 @@
1
+ /**
2
+ * S3 batch sink — satırları biriktirir, aralık / maxBatch ile PutObject atar.
3
+ *
4
+ * Başarısız bir yükleme süreci düşürmez: bir kez uyarır, batch'i bırakır.
5
+ * Backpressure için yeniden kuyruk yok — log kaybı, belleğin şişmesinden
6
+ * tercih edilir.
7
+ */
8
+ import os from "node:os";
9
+ import process from "node:process";
10
+ import { putObject } from "./s3-put.js";
11
+
12
+ /**
13
+ * @typedef {import('./file-sink.js').LogSink} LogSink
14
+ * @typedef {import('./s3-put.js').AwsCredentials} AwsCredentials
15
+ */
16
+
17
+ /**
18
+ * @param {{ bucket: string, prefix: string, region: string,
19
+ * endpoint?: string | null, credentials: AwsCredentials,
20
+ * flushIntervalMs: number, maxBatch: number,
21
+ * put?: typeof putObject }} options
22
+ * @returns {LogSink & { pendingCount: () => number }}
23
+ */
24
+ export function createS3Sink(options) {
25
+ /** @type {string[]} */
26
+ let buffer = [];
27
+ let seq = 0;
28
+ let warned = false;
29
+ /** @type {Promise<void>} */
30
+ let chain = Promise.resolve();
31
+ /** @type {ReturnType<typeof setInterval> | null} */
32
+ let timer = null;
33
+
34
+ const put = options.put ?? putObject;
35
+ const host = os.hostname().replace(/[^a-zA-Z0-9._-]/g, "-") || "host";
36
+ const prefix = options.prefix.endsWith("/")
37
+ ? options.prefix
38
+ : `${options.prefix}/`;
39
+
40
+ /**
41
+ * @param {Date} [date]
42
+ * @returns {string}
43
+ */
44
+ function objectKey(date = new Date()) {
45
+ const y = date.getUTCFullYear();
46
+ const m = String(date.getUTCMonth() + 1).padStart(2, "0");
47
+ const d = String(date.getUTCDate()).padStart(2, "0");
48
+ const iso = date.toISOString().replace(/[:.]/g, "-");
49
+ seq += 1;
50
+ return `${prefix}${y}/${m}/${d}/${host}-${process.pid}-${iso}-${seq}.ndjson`;
51
+ }
52
+
53
+ /**
54
+ * @param {string} message
55
+ * @param {unknown} [error]
56
+ */
57
+ function warnOnce(message, error) {
58
+ if (warned) return;
59
+ warned = true;
60
+ console.warn(`[logs] ${message}`, error ?? "");
61
+ }
62
+
63
+ async function flushNow() {
64
+ if (!buffer.length) return;
65
+ const lines = buffer;
66
+ buffer = [];
67
+ const body = `${lines.join("\n")}\n`;
68
+ try {
69
+ await put({
70
+ bucket: options.bucket,
71
+ key: objectKey(),
72
+ body,
73
+ region: options.region,
74
+ endpoint: options.endpoint ?? null,
75
+ credentials: options.credentials,
76
+ });
77
+ } catch (error) {
78
+ warnOnce("S3 PutObject failed; dropping batch", error);
79
+ }
80
+ }
81
+
82
+ /** Flush'ları sıraya dizer — iki timer çakışmasın. */
83
+ function enqueueFlush() {
84
+ chain = chain.then(flushNow).catch(() => {});
85
+ return chain;
86
+ }
87
+
88
+ timer = setInterval(() => {
89
+ void enqueueFlush();
90
+ }, options.flushIntervalMs);
91
+ timer.unref?.();
92
+
93
+ return {
94
+ pendingCount() {
95
+ return buffer.length;
96
+ },
97
+ async write(entry) {
98
+ buffer.push(JSON.stringify(entry));
99
+ if (buffer.length >= options.maxBatch) await enqueueFlush();
100
+ },
101
+ async flush() {
102
+ await enqueueFlush();
103
+ },
104
+ async close() {
105
+ if (timer) {
106
+ clearInterval(timer);
107
+ timer = null;
108
+ }
109
+ await enqueueFlush();
110
+ },
111
+ };
112
+ }