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 +6 -0
- package/docs/07-yapilandirma.md +45 -0
- package/docs/10-dagitim.md +5 -0
- package/docs/en/07-configuration.md +45 -0
- package/docs/en/10-deployment.md +5 -0
- package/package.json +1 -1
- package/src/config/defaults.js +38 -0
- package/src/config/index.js +91 -2
- package/src/log.mjs +73 -43
- package/src/server/create-app.js +23 -6
- package/src/server/logs/access-middleware.js +66 -0
- package/src/server/logs/file-sink.js +66 -0
- package/src/server/logs/pipeline.js +164 -0
- package/src/server/logs/s3-put.js +214 -0
- package/src/server/logs/s3-sink.js +112 -0
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
|
package/docs/07-yapilandirma.md
CHANGED
|
@@ -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 |
|
package/docs/10-dagitim.md
CHANGED
|
@@ -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 |
|
package/docs/en/10-deployment.md
CHANGED
|
@@ -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.
|
|
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",
|
package/src/config/defaults.js
CHANGED
|
@@ -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
|
*
|
package/src/config/index.js
CHANGED
|
@@ -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
|
-
|
|
249
|
-
|
|
270
|
+
if (consoleEnabled) {
|
|
271
|
+
const body = `${message}${note ? ` ${note}` : ""}`;
|
|
272
|
+
const padding = " ".repeat(Math.max(1, DETAIL - body.length));
|
|
250
273
|
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
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
|
-
|
|
274
|
-
|
|
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
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
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
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
const
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
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
|
-
|
|
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",
|
package/src/server/create-app.js
CHANGED
|
@@ -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
|
|
252
|
-
* bitmesini bekliyor; sert `disconnect` yarıda
|
|
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().
|
|
280
|
-
|
|
281
|
-
|
|
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
|
+
}
|