jskelet 0.6.2 → 0.6.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/CHANGELOG.md +32 -0
  2. package/README.md +2 -0
  3. package/docs/02-mimari.md +1 -0
  4. package/docs/04-render-ve-sablonlar.md +9 -3
  5. package/docs/06-cache.md +53 -9
  6. package/docs/07-yapilandirma.md +1208 -1191
  7. package/docs/08-build.md +2 -1
  8. package/docs/10-dagitim.md +16 -6
  9. package/docs/12-panel-ve-oturum.md +2 -1
  10. package/docs/en/02-architecture.md +2 -1
  11. package/docs/en/04-rendering.md +9 -3
  12. package/docs/en/06-caching.md +54 -9
  13. package/docs/en/07-configuration.md +24 -9
  14. package/docs/en/08-build.md +2 -1
  15. package/docs/en/10-deployment.md +17 -6
  16. package/docs/en/12-dashboards-and-sessions.md +2 -1
  17. package/package.json +1 -1
  18. package/src/config/defaults.js +541 -518
  19. package/src/config/index.js +1500 -1456
  20. package/src/init.mjs +2 -0
  21. package/src/server/cache-blob.js +70 -0
  22. package/src/server/cache-control.js +45 -0
  23. package/src/server/data-cache.js +118 -27
  24. package/src/server/disk-cache.js +233 -0
  25. package/src/server/html-cache.js +90 -16
  26. package/src/server/image-optimizer.js +95 -2
  27. package/src/server/logs/file-sink.js +159 -32
  28. package/src/server/logs/pipeline.js +10 -3
  29. package/src/server/middleware/static-precompressed.js +31 -10
  30. package/src/server/og-image.js +17 -4
  31. package/src/server/prewarm.js +25 -1
  32. package/src/server/redis.js +31 -12
  33. package/src/server/render.js +910 -910
  34. package/types/config/defaults.d.ts +21 -1
  35. package/types/config/index.d.ts +14 -0
  36. package/types/server/cache-blob.d.ts +13 -0
  37. package/types/server/cache-control.d.ts +28 -0
  38. package/types/server/data-cache.d.ts +9 -0
  39. package/types/server/disk-cache.d.ts +36 -0
  40. package/types/server/html-cache.d.ts +26 -3
  41. package/types/server/logs/file-sink.d.ts +16 -5
  42. package/types/server/og-image.d.ts +5 -0
  43. package/types/server/redis.d.ts +2 -1
@@ -1,1456 +1,1500 @@
1
- /**
2
- * `jskelet.config.mjs` yükleyicisi ve çözümlenmiş proje durumu.
3
- *
4
- * Bu modül framework'ün **tek gerçek kaynağıdır**: proje kökü, dizin yolları,
5
- * markalama, hook'lar ve `headers/redirects/rewrites/cache` kuralları burada
6
- * normalize edilir. Diğer modüller yol hesaplamaz, `getConfig()` çağırır.
7
- * Böylece framework `node_modules/` içine girdiğinde hiçbir dosyada
8
- * `../..` sayma hatası oluşmaz.
9
- *
10
- * Config dosyası **zorunlu değildir**: yoksa ya da okunamıyorsa uyarı basılır
11
- * ve sunucu varsayılanlarla ayağa kalkar. Bozuk bir düzenleme siteyi
12
- * açılamaz hâle getirmemeli.
13
- *
14
- * Desteklenen bölümler (hepsi opsiyonel, hepsi `async` olabilir):
15
- * headers() → [{ source, headers: [{ key, value }] }]
16
- * redirects() → [{ source, destination, permanent?, statusCode? }]
17
- * rewrites() → [{ source, destination }] | { beforeFiles?, afterFiles? }
18
- * cache() → { html?: { [source]: saniye },
19
- * query?: { [source]: string[] | true },
20
- * vary?: { host?: boolean, headers?: string[], fn?: Function },
21
- * maxEntries?: number,
22
- * data?: {...}, redis?: {...}, prewarm?: {...} }
23
- * admin() → { enabled?, basePath?, allowIps?, blockBots?, … }
24
- * auth → { crossSubdomainHandoff?: boolean | object }
25
- * logs → { console?, kinds?, file?, s3? }
26
- *
27
- * Fonksiyon olmayan bölümler (`brand`, `security`, `static`, `navigation`…)
28
- * düz nesne olarak okunur. `logs` fonksiyon ya da düz nesne olabilir.
29
- */
30
- import fs from "node:fs";
31
- import path from "node:path";
32
- import process from "node:process";
33
- import { pathToFileURL } from "node:url";
34
- import { compilePattern, matchPattern } from "./pattern.js";
35
- import {
36
- DEFAULT_ADMIN,
37
- DEFAULT_AUTH,
38
- DEFAULT_BRAND,
39
- DEFAULT_CLOUDFLARE,
40
- DATA_CACHE_MAX_ENTRIES_CEILING,
41
- DEFAULT_DATA_CACHE,
42
- DEFAULT_DEV_GATE_BYPASS,
43
- DEFAULT_DIRS,
44
- DEFAULT_HTML_CACHE_MAX_ENTRIES,
45
- HTML_CACHE_MAX_ENTRIES_CEILING,
46
- ON_VISIT_CONCURRENCY_CEILING,
47
- ON_VISIT_PER_PAGE_CEILING,
48
- ON_VISIT_RPS_CEILING,
49
- DEFAULT_IMAGES,
50
- DEFAULT_LOGS,
51
- DEFAULT_NAVIGATION,
52
- DEFAULT_NAVIGATION_EXCLUDE,
53
- DEFAULT_PREWARM,
54
- DEFAULT_PREWARM_ON_VISIT,
55
- DEFAULT_PREWARM_SKIP,
56
- CLASSIC_PREWARM_KEYS,
57
- DEFAULT_REDIS,
58
- DEFAULT_SECURITY,
59
- DEFAULT_STATIC,
60
- DEFAULT_TRANSIENT_RETRY,
61
- DEFAULT_UPSTREAM_LIMIT,
62
- } from "./defaults.js";
63
-
64
- /** Framework paketinin kökü — kendi şablonlarına ve varlıklarına erişir. */
65
- export const FRAMEWORK_ROOT = path.resolve(import.meta.dirname, "..", "..");
66
-
67
- const CONFIG_FILE = "jskelet.config.mjs";
68
-
69
- /**
70
- * @typedef {"conservative" | "moderate" | "eager"} Eagerness
71
- *
72
- * @typedef {object} NavigationConfig
73
- * @property {false | Eagerness} prefetch
74
- * @property {false | Eagerness} prerender
75
- * @property {boolean} viewTransition
76
- * @property {string[]} exclude Spekülasyon dışı bırakılan href desenleri.
77
- */
78
-
79
- /**
80
- * @typedef {object} RedisConfig
81
- * @property {boolean} enabled
82
- * @property {string | null} url
83
- * @property {string} namespace
84
- * @property {string} keyPrefix
85
- * @property {boolean} html HTML gövdeleri paylaşılsın mı.
86
- * @property {boolean} data Veri önbelleği paylaşılsın mı.
87
- * @property {boolean} storeEncoded Sıkıştırılmış gövdeler de paylaşılsın mı.
88
- * @property {boolean} events pub/sub invalidation yayını.
89
- * @property {number} commandTimeoutMs
90
- */
91
-
92
- /**
93
- * @typedef {"http" | "event" | "error"} LogKind
94
- *
95
- * @typedef {object} LogsConfig
96
- * @property {boolean} console Runtime http/event/error satırları stdout'a
97
- * basılsın mı (banner/build satırları etkilenmez).
98
- * @property {LogKind[]} kinds Sink'lere giden kayıt türleri.
99
- * @property {{ enabled: boolean, dir: string, rotate: "daily" }} file
100
- * @property {{ enabled: boolean, bucket: string | null, prefix: string,
101
- * region: string | null, endpoint: string | null, flushIntervalMs: number,
102
- * maxBatch: number }} s3
103
- */
104
-
105
- /**
106
- * @typedef {import('./pattern.js').CompiledPattern} CompiledPattern
107
- *
108
- * @typedef {object} ResolvedConfig
109
- * @property {string} root Proje kökü (mutlak).
110
- * @property {boolean} loaded Config dosyası okundu mu.
111
- * @property {Record<string, string>} dirs Mutlak dizin yolları.
112
- * @property {{ pattern: CompiledPattern, headers: { key: string, value: string }[] }[]} headers
113
- * @property {{ pattern: CompiledPattern, destination: string, statusCode: number }[]} redirects
114
- * @property {{ phase: "beforeFiles" | "afterFiles", pattern: CompiledPattern, destination: string }[]} rewrites
115
- * @property {{ pattern: CompiledPattern, seconds: number }[]} html
116
- * @property {{ pattern: CompiledPattern, allow: true | string[] }[]} cacheQuery
117
- * Yol deseni başına, HTML cache anahtarına girmesine izin verilen query
118
- * parametreleri. Eşleşen kural yoksa query'li istek cache'lenmez.
119
- * @property {{ host: boolean, headers: string[],
120
- * fn: ((req: import('express').Request) => string | null | undefined) | null }} cacheVary
121
- * Anahtara eklenen sabit parçalar (query allowlist'ten bağımsız). Host'tan
122
- * locale üreten sitelerde `host: true` zorunlu.
123
- * @property {number} htmlMaxEntries HTML önbelleğinin girdi sınırı.
124
- * @property {Record<string, unknown>} data Upstream veri önbelleği ayarları.
125
- * @property {boolean} trackUpstream `fetch` sarılıp geçici hatalar otomatik bildirilsin mi.
126
- * @property {boolean} trackDependencies Render'ın okuduğu veri anahtarları kaydedilsin mi.
127
- * @property {{ attempts: number, delayMs: number }} transientRetry
128
- * @property {RedisConfig} redis Opsiyonel Redis ikinci kademesi.
129
- * @property {typeof DEFAULT_UPSTREAM_LIMIT} upstream Upstream hız freni.
130
- * @property {LogsConfig} logs Kalıcı log sink'leri (dosya + S3).
131
- * @property {typeof DEFAULT_ADMIN} admin Framework yönetim paneli.
132
- * @property {typeof DEFAULT_CLOUDFLARE} cloudflare Cloudflare cache yüzeyi.
133
- * @property {Record<string, unknown>} prewarm
134
- * @property {{ source: string, test: (pathname: string) => boolean }[]} prewarmPriority
135
- * @property {Record<string, unknown>} brand
136
- * @property {{ crossSubdomainHandoff: boolean | Record<string, unknown> }} auth
137
- * @property {Record<string, Function>} hooks
138
- * @property {string} layout Layout `.ejs` dosyasının mutlak yolu.
139
- * @property {string[] | null} routes Açık route modülü listesi.
140
- * @property {boolean} trailingSlash URL'ler `/` ile bitsin mi (Next `trailingSlash`).
141
- * @property {{ extensions: Set<string>, prefixes: string[] }} static
142
- * @property {boolean} devGate `DEV_TOKEN` tek başına siteyi kilitlemez; gate
143
- * ancak bu bayrak veya `DEV_GATE=1` ile açılır.
144
- * @property {string[]} devGateBypass
145
- * @property {string[]} preconnect
146
- * @property {NavigationConfig} navigation
147
- * @property {SecurityConfig} security
148
- * @property {string[]} prewarmSkip
149
- * @property {string[]} watch Dev sunucusunun izlediği ek dizinler.
150
- * @property {{ family: string, slug?: string, weights: number[] }[]} fonts
151
- * @property {{ scan?: string[], dir: string } | false} icons
152
- * @property {ImagesConfig | false} images
153
- * @property {string[]} clientEnv Client bundle'a gömülecek env anahtarları.
154
- */
155
-
156
- /**
157
- * @typedef {object} ImagesRemoteConfig
158
- * @property {boolean} enabled
159
- * @property {string[]} allowHosts
160
- * @property {string} path
161
- * @property {number} maxWidth
162
- * @property {number} cacheMaxAge
163
- * @property {number} fetchTimeoutMs
164
- * @property {number} maxBytes
165
- */
166
-
167
- /**
168
- * @typedef {object} ImagesConfig
169
- * @property {number[]} widths
170
- * @property {number} quality
171
- * @property {string[]} skip
172
- * @property {ImagesRemoteConfig | false} remote
173
- */
174
-
175
- /** @type {ResolvedConfig | null} */
176
- let config = null;
177
-
178
- /**
179
- * @param {unknown} value
180
- * @param {string} label
181
- * @returns {unknown[]}
182
- */
183
- function asArray(value, label) {
184
- if (value == null) return [];
185
- if (Array.isArray(value)) return value;
186
- console.warn(`[config] ${label} must return an array, ignoring it`);
187
- return [];
188
- }
189
-
190
- /**
191
- * @param {unknown} raw
192
- * @returns {ResolvedConfig["headers"]}
193
- */
194
- function normalizeHeaders(raw) {
195
- /** @type {ResolvedConfig["headers"]} */
196
- const out = [];
197
-
198
- for (const entry of asArray(raw, "headers()")) {
199
- const pattern = compilePattern(entry?.source);
200
- if (!pattern) continue;
201
-
202
- const headers = asArray(entry?.headers, "headers()[].headers")
203
- .filter((header) => header?.key && header?.value !== undefined)
204
- .map((header) => ({ key: String(header.key), value: String(header.value) }));
205
-
206
- if (headers.length) out.push({ pattern, headers });
207
- }
208
-
209
- return out;
210
- }
211
-
212
- /**
213
- * @param {unknown} raw
214
- * @returns {ResolvedConfig["redirects"]}
215
- */
216
- function normalizeRedirects(raw) {
217
- /** @type {ResolvedConfig["redirects"]} */
218
- const out = [];
219
-
220
- for (const entry of asArray(raw, "redirects()")) {
221
- const pattern = compilePattern(entry?.source);
222
- if (!pattern || typeof entry?.destination !== "string") continue;
223
-
224
- // Next semantiği: permanent → 308, geçici → 307. Farklı bir kod isteyen
225
- // `statusCode` verebilir (ör. eski kurulumlarla uyum için 301).
226
- const statusCode = Number(entry.statusCode) || (entry.permanent ? 308 : 307);
227
-
228
- out.push({ pattern, destination: entry.destination, statusCode });
229
- }
230
-
231
- return out;
232
- }
233
-
234
- /**
235
- * @param {unknown} raw
236
- * @returns {ResolvedConfig["rewrites"]}
237
- */
238
- function normalizeRewrites(raw) {
239
- /** @type {ResolvedConfig["rewrites"]} */
240
- const out = [];
241
-
242
- /** @type {[("beforeFiles" | "afterFiles"), unknown][]} */
243
- const phases = Array.isArray(raw)
244
- ? [["afterFiles", raw]]
245
- : [
246
- ["beforeFiles", raw?.beforeFiles],
247
- ["afterFiles", raw?.afterFiles],
248
- ];
249
-
250
- for (const [phase, entries] of phases) {
251
- for (const entry of asArray(entries, `rewrites().${phase}`)) {
252
- const pattern = compilePattern(entry?.source);
253
- if (!pattern || typeof entry?.destination !== "string") continue;
254
- out.push({ phase, pattern, destination: entry.destination });
255
- }
256
- }
257
-
258
- return out;
259
- }
260
-
261
- /**
262
- * Isıtma sırası desenleri. İki biçim kabul edilir: config'in her yerinde
263
- * geçerli olan `/haber/:slug` sözdizimi ve doğrudan `RegExp` — ikincisi
264
- * "sonu `-yorumlar` ile bitenler" gibi desen sözdiziminin karşılamadığı
265
- * kuralları yazabilmek için.
266
- *
267
- * @param {unknown} raw
268
- * @returns {ResolvedConfig["prewarmPriority"]}
269
- */
270
- function normalizePriority(raw) {
271
- /** @type {ResolvedConfig["prewarmPriority"]} */
272
- const out = [];
273
-
274
- for (const entry of asArray(raw, "cache().prewarm.priority")) {
275
- if (entry instanceof RegExp) {
276
- out.push({ source: String(entry), test: (pathname) => entry.test(pathname) });
277
- continue;
278
- }
279
-
280
- const pattern = compilePattern(entry);
281
- if (!pattern) continue;
282
- out.push({
283
- source: pattern.source,
284
- test: (pathname) => matchPattern(pattern, pathname) !== null,
285
- });
286
- }
287
-
288
- return out;
289
- }
290
-
291
- /**
292
- * Redis bölümü. Bozuk bir değer sunucuyu düşürmemeli: her alan tipine
293
- * zorlanır ve `enabled` yalnızca açıkça `true` verildiğinde açılır.
294
- *
295
- * @param {unknown} raw
296
- * @returns {RedisConfig}
297
- */
298
- function normalizeRedis(raw) {
299
- const source = /** @type {Record<string, any>} */ (raw ?? {});
300
- const timeout = Number(source.commandTimeoutMs);
301
-
302
- return {
303
- enabled: source.enabled === true,
304
- url: typeof source.url === "string" && source.url ? source.url : null,
305
- namespace: String(source.namespace ?? DEFAULT_REDIS.namespace),
306
- keyPrefix: String(source.keyPrefix ?? DEFAULT_REDIS.keyPrefix),
307
- html: source.html !== false,
308
- data: source.data !== false,
309
- storeEncoded: source.storeEncoded === true,
310
- events: source.events !== false,
311
- commandTimeoutMs:
312
- Number.isFinite(timeout) && timeout > 0
313
- ? Math.floor(timeout)
314
- : DEFAULT_REDIS.commandTimeoutMs,
315
- };
316
- }
317
-
318
- /**
319
- * Upstream hız freni. Sayısal alanlar tipine zorlanır; bozuk bir değer freni
320
- * yanlış ayarlamak yerine varsayılana döner.
321
- *
322
- * @param {unknown} raw
323
- * @returns {typeof DEFAULT_UPSTREAM_LIMIT}
324
- */
325
- function normalizeUpstream(raw) {
326
- const source = /** @type {Record<string, any>} */ (raw ?? {});
327
- const merged = { ...DEFAULT_UPSTREAM_LIMIT, ...source };
328
-
329
- /** @param {string} key */
330
- const positive = (key) => {
331
- const value = Number(merged[key]);
332
- return Number.isFinite(value) && value >= 0
333
- ? value
334
- : /** @type {any} */ (DEFAULT_UPSTREAM_LIMIT)[key];
335
- };
336
-
337
- /** @type {Record<string, Record<string, number>>} */
338
- const hosts = {};
339
- for (const [host, override] of Object.entries(merged.hosts ?? {})) {
340
- if (override && typeof override === "object") hosts[host] = override;
341
- }
342
-
343
- return {
344
- ...merged,
345
- rate: positive("rate"),
346
- burst: positive("burst"),
347
- concurrency: Math.max(1, Math.floor(positive("concurrency"))),
348
- minRate: positive("minRate"),
349
- increaseStep: positive("increaseStep"),
350
- increaseIntervalMs: positive("increaseIntervalMs"),
351
- decreaseIntervalMs: positive("decreaseIntervalMs"),
352
- breakerFailures: Math.floor(positive("breakerFailures")),
353
- breakerCooldownMs: positive("breakerCooldownMs"),
354
- hosts,
355
- };
356
- }
357
-
358
- /**
359
- * Dev gate. `DEV_TOKEN` ortamda durması siteyi kilitlemez: paylaşılan bir
360
- * task tanımı production'a da aynı değişkeni taşır ve herkese 404 olur.
361
- * Gate ancak `devGate: true` ya da `DEV_GATE=1` ile açılır. `DEV_GATE=0`
362
- * config'teki açığı da kapatır.
363
- *
364
- * @param {Record<string, any>} source
365
- * @returns {boolean}
366
- */
367
- function normalizeDevGate(source) {
368
- const env = process.env.DEV_GATE;
369
- if (env === "0" || env === "false") return false;
370
- if (env === "1" || env === "true") return true;
371
- return source.devGate === true;
372
- }
373
-
374
- /**
375
- * Yönetim paneli. `enabled` yalnızca açıkça `true` verildiğinde ya da
376
- * `JSKELET_ADMIN` ortam değişkeni ayarlandığında açılır: paneli yanlışlıkla
377
- * açmanın bedeli, önbelleği boşaltabilen bir ucu internete koymak.
378
- *
379
- * Ortam değişkeni config'in **üstünde** duruyor, çünkü paneli genelde bir
380
- * arıza sırasında tek seferlik açmak isteniyor ve o an config dosyasını
381
- * değiştirip yeniden dağıtmak istenmiyor. `JSKELET_ADMIN=0` aynı mantıkla
382
- * config'te açık olan paneli kapatır.
383
- *
384
- * @param {unknown} raw
385
- * @returns {typeof DEFAULT_ADMIN}
386
- */
387
- function normalizeAdmin(raw) {
388
- const source = /** @type {Record<string, any>} */ (raw ?? {});
389
- const env = process.env.JSKELET_ADMIN;
390
-
391
- const basePath =
392
- typeof source.basePath === "string" && source.basePath.startsWith("/")
393
- ? source.basePath.replace(/\/+$/, "")
394
- : DEFAULT_ADMIN.basePath;
395
-
396
- /** @param {string} key @param {number} min */
397
- const positive = (key, min) => {
398
- const value = Number(source[key]);
399
- return Number.isFinite(value) && value >= min
400
- ? value
401
- : /** @type {any} */ (DEFAULT_ADMIN)[key];
402
- };
403
-
404
- const allowIps = Array.isArray(source.allowIps)
405
- ? source.allowIps
406
- .filter((entry) => typeof entry === "string" && entry.trim())
407
- .map((entry) => entry.trim())
408
- : [...DEFAULT_ADMIN.allowIps];
409
-
410
- const logSize = Number(source.logSize);
411
-
412
- return {
413
- enabled:
414
- env === undefined
415
- ? source.enabled === true
416
- : env !== "0" && env !== "false" && env !== "",
417
- basePath: basePath || DEFAULT_ADMIN.basePath,
418
- allowIps,
419
- blockBots: source.blockBots !== false,
420
- banAttempts: Math.floor(positive("banAttempts", 1)),
421
- banHours: positive("banHours", 0),
422
- sessionHours: positive("sessionHours", 0),
423
- logSize:
424
- Number.isFinite(logSize) && logSize >= 50
425
- ? Math.min(5000, Math.floor(logSize))
426
- : DEFAULT_ADMIN.logSize,
427
- };
428
- }
429
-
430
- /**
431
- * Cloudflare bölümü. Token burada da verilebiliyor ama önerilen yol env;
432
- * normalizasyon sadece tipleri sabitler, sırrı okumak `cloudflare.js`'in işi.
433
- *
434
- * @param {unknown} raw
435
- * @returns {typeof DEFAULT_CLOUDFLARE}
436
- */
437
- function normalizeCloudflare(raw) {
438
- const source = /** @type {Record<string, any>} */ (raw ?? {});
439
- const hours = Number(source.analyticsHours);
440
-
441
- /** @param {unknown} value */
442
- const text = (value) => (typeof value === "string" && value ? value : null);
443
-
444
- return {
445
- enabled: source.enabled !== false,
446
- zoneId: text(source.zoneId),
447
- apiToken: text(source.apiToken),
448
- // Şema yazılırsa purge URL'i `https://https://…` olur; baştaki şema atılır.
449
- hostname: text(source.hostname)?.replace(/^https?:\/\//, "") ?? null,
450
- analyticsHours:
451
- Number.isFinite(hours) && hours > 0
452
- ? Math.min(72, Math.floor(hours))
453
- : DEFAULT_CLOUDFLARE.analyticsHours,
454
- };
455
- }
456
-
457
- const LOG_KINDS = new Set(["http", "event", "error"]);
458
-
459
- /**
460
- * `bucket`, `JSKELET_LOG_BUCKET` veya `JSKELET_S3_BUCKET` değeri
461
- * `ayberkenis/jskelet/logs` gibi bir yol olabilir: ilk segment bucket adı,
462
- * kalanı nesne öneki. Böylece tek env ile hem kova hem klasör verilmiş olur.
463
- *
464
- * @param {string | null} value
465
- * @returns {{ bucket: string | null, prefix: string | null }}
466
- * `prefix` null → yol öneki taşımıyor; config/varsayılan kalsın.
467
- */
468
- export function splitS3BucketPath(value) {
469
- if (!value) return { bucket: null, prefix: null };
470
-
471
- const trimmed = value.replace(/^\/+|\/+$/g, "");
472
- if (!trimmed) return { bucket: null, prefix: null };
473
-
474
- const slash = trimmed.indexOf("/");
475
- if (slash < 0) return { bucket: trimmed, prefix: null };
476
-
477
- const bucket = trimmed.slice(0, slash);
478
- const rest = trimmed.slice(slash + 1).replace(/^\/+|\/+$/g, "");
479
- if (!bucket) return { bucket: null, prefix: null };
480
-
481
- return {
482
- bucket,
483
- prefix: rest ? `${rest}/` : null,
484
- };
485
- }
486
-
487
- /**
488
- * @param {unknown} value
489
- * @returns {string | null}
490
- */
491
- function envText(value) {
492
- return typeof value === "string" && value ? value : null;
493
- }
494
-
495
- /**
496
- * @returns {{ accessKeyId: string, secretAccessKey: string,
497
- * sessionToken: string | null } | null}
498
- */
499
- export function readS3CredentialsFromEnv() {
500
- const accessKeyId = envText(process.env.JSKELET_S3_ACCESS_KEY_ID);
501
- const secretAccessKey =
502
- envText(process.env.JSKELET_S3_SECRET_ACCESS_KEY) ??
503
- envText(process.env.JSKELET_S3_ACCESS_SECRET);
504
- if (!accessKeyId || !secretAccessKey) return null;
505
- return {
506
- accessKeyId,
507
- secretAccessKey,
508
- sessionToken: envText(process.env.JSKELET_S3_SESSION_TOKEN),
509
- };
510
- }
511
-
512
- /**
513
- * Log hedefi. Öncelik: `JSKELET_LOG_BUCKET` → `JSKELET_S3_BUCKET`
514
- * (+ isteğe bağlı `JSKELET_S3_KEY_PREFIX`).
515
- *
516
- * @returns {string | null}
517
- */
518
- function resolveLogBucketEnv() {
519
- const logPath = envText(process.env.JSKELET_LOG_BUCKET);
520
- if (logPath) return logPath;
521
-
522
- const bucket = envText(process.env.JSKELET_S3_BUCKET);
523
- if (!bucket) return null;
524
-
525
- const prefix = envText(process.env.JSKELET_S3_KEY_PREFIX);
526
- return prefix ? `${bucket}/${prefix}` : bucket;
527
- }
528
-
529
- /**
530
- * Kalıcı log sink'leri. Bozuk bir `kinds` listesi siteyi düşürmemeli —
531
- * bilinmeyen girdiler atılır; hiç geçerli tür kalmazsa varsayılana dönülür.
532
- *
533
- * @param {unknown} raw
534
- * @returns {LogsConfig}
535
- */
536
- export function normalizeLogs(raw) {
537
- const source = /** @type {Record<string, any>} */ (raw ?? {});
538
- const fileRaw = /** @type {Record<string, any>} */ (source.file ?? {});
539
- const s3Raw = /** @type {Record<string, any>} */ (source.s3 ?? {});
540
-
541
- /** @type {LogKind[]} */
542
- let kinds = DEFAULT_LOGS.kinds;
543
- if (Array.isArray(source.kinds)) {
544
- const filtered = source.kinds.filter(
545
- (entry) => typeof entry === "string" && LOG_KINDS.has(entry),
546
- );
547
- if (filtered.length) kinds = /** @type {LogKind[]} */ ([...new Set(filtered)]);
548
- else {
549
- console.warn(
550
- "[config] logs.kinds has no valid entries (http|event|error), using defaults",
551
- );
552
- }
553
- } else if (source.kinds != null) {
554
- console.warn("[config] logs.kinds must be an array, using defaults");
555
- }
556
-
557
- const flush = Number(s3Raw.flushIntervalMs);
558
- const batch = Number(s3Raw.maxBatch);
559
-
560
- const bucketPath = splitS3BucketPath(
561
- resolveLogBucketEnv() ?? envText(s3Raw.bucket),
562
- );
563
-
564
- const configPrefix =
565
- typeof s3Raw.prefix === "string" && s3Raw.prefix
566
- ? s3Raw.prefix.endsWith("/")
567
- ? s3Raw.prefix
568
- : `${s3Raw.prefix}/`
569
- : DEFAULT_LOGS.s3.prefix;
570
-
571
- const endpoint =
572
- envText(process.env.JSKELET_S3_API_URL) ?? envText(s3Raw.endpoint);
573
-
574
- // Uyumlu API'lerde (Cloudflare R2 vb.) imza bölgesi çoğu zaman `auto`.
575
- // Region hiçbir kurulumda zorunlu değil.
576
- const region =
577
- envText(s3Raw.region) ??
578
- envText(process.env.JSKELET_S3_REGION) ??
579
- "auto";
580
-
581
- const credentials = readS3CredentialsFromEnv();
582
- // `JSKELET_LOG_BUCKET` (veya S3 bucket) + credential varsa config'te
583
- // `enabled: true` unutulmuş olsa bile aç. Açık `enabled: false` ezer.
584
- const envWantsLogs = Boolean(
585
- envText(process.env.JSKELET_LOG_BUCKET) ||
586
- envText(process.env.JSKELET_S3_BUCKET),
587
- );
588
- const enabled =
589
- s3Raw.enabled === true ||
590
- (s3Raw.enabled !== false &&
591
- envWantsLogs &&
592
- Boolean(credentials) &&
593
- Boolean(bucketPath.bucket));
594
-
595
- return {
596
- console: source.console !== false,
597
- kinds,
598
- file: {
599
- enabled: fileRaw.enabled === true,
600
- dir:
601
- typeof fileRaw.dir === "string" && fileRaw.dir.trim()
602
- ? fileRaw.dir.trim()
603
- : DEFAULT_LOGS.file.dir,
604
- rotate: "daily",
605
- },
606
- s3: {
607
- enabled,
608
- bucket: bucketPath.bucket,
609
- // Yoldaki önek tek env ile klasör vermeyi mümkün kılar; yoksa config.
610
- prefix: bucketPath.prefix ?? configPrefix,
611
- region,
612
- endpoint,
613
- flushIntervalMs:
614
- Number.isFinite(flush) && flush >= 500
615
- ? Math.min(60_000, Math.floor(flush))
616
- : DEFAULT_LOGS.s3.flushIntervalMs,
617
- maxBatch:
618
- Number.isFinite(batch) && batch >= 1
619
- ? Math.min(5000, Math.floor(batch))
620
- : DEFAULT_LOGS.s3.maxBatch,
621
- },
622
- };
623
- }
624
-
625
- /**
626
- * `cache().query` → yol deseni başına, cache anahtarına girmesine izin verilen
627
- * query parametreleri.
628
- *
629
- * Varsayılan bilinçli olarak "query varsa sayfa dinamik": bir yolun bütün
630
- * query varyantlarını cache'lemek, `?utm_source=…` gibi sonsuz sayıda anahtar
631
- * üretip LRU'daki gerçek sayfaları dışarı atıyor. Hangi parametrenin çıktıyı
632
- * gerçekten değiştirdiğini yalnızca uygulama bilir, o yüzden izin listesi
633
- * config'ten gelir.
634
- *
635
- * Bir desen `true` ile eşlenirse bütün parametreler anahtara girer (eski
636
- * davranış), `[]` ile eşlenirse hiçbiri girmez — yani query yok sayılır ve
637
- * bütün varyantlar query'siz sürümün HTML'ini paylaşır.
638
- *
639
- * @param {unknown} raw
640
- * @returns {ResolvedConfig["cacheQuery"]}
641
- */
642
- function normalizeQueryRules(raw) {
643
- /** @type {ResolvedConfig["cacheQuery"]} */
644
- const out = [];
645
-
646
- for (const [source, value] of Object.entries(raw ?? {})) {
647
- const pattern = compilePattern(source);
648
- if (!pattern) continue;
649
-
650
- if (value === true) {
651
- out.push({ pattern, allow: true });
652
- continue;
653
- }
654
- if (value === false) continue;
655
-
656
- const allow = asArray(
657
- typeof value === "string" ? [value] : value,
658
- `cache().query["${source}"]`,
659
- )
660
- .filter((name) => typeof name === "string" && name)
661
- .map(String);
662
- out.push({ pattern, allow });
663
- }
664
-
665
- return out;
666
- }
667
-
668
- /**
669
- * `cache().vary` → HTML anahtarına host / header / özel fn parçası.
670
- *
671
- * @param {unknown} raw
672
- * @returns {ResolvedConfig["cacheVary"]}
673
- */
674
- function normalizeVary(raw) {
675
- if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
676
- return { host: false, headers: [], fn: null };
677
- }
678
-
679
- const source = /** @type {Record<string, unknown>} */ (raw);
680
- const headers = asArray(source.headers, "cache().vary.headers")
681
- .filter((name) => typeof name === "string" && name)
682
- .map((name) => String(name).toLowerCase());
683
-
684
- /** @type {ResolvedConfig["cacheVary"]["fn"]} */
685
- let fn = null;
686
- if (typeof source.fn === "function") {
687
- fn = /** @type {ResolvedConfig["cacheVary"]["fn"]} */ (source.fn);
688
- } else if (source.fn != null) {
689
- console.warn("[config] cache().vary.fn must be a function, ignoring it");
690
- }
691
-
692
- return {
693
- host: source.host === true,
694
- headers,
695
- fn,
696
- };
697
- }
698
-
699
- /**
700
- * @param {unknown} raw
701
- * @returns {{ html: ResolvedConfig["html"],
702
- * cacheQuery: ResolvedConfig["cacheQuery"],
703
- * cacheVary: ResolvedConfig["cacheVary"], htmlMaxEntries: number,
704
- * data: Record<string, unknown>, trackUpstream: boolean,
705
- * trackDependencies: boolean,
706
- * transientRetry: { attempts: number, delayMs: number },
707
- * redis: RedisConfig,
708
- * upstream: typeof DEFAULT_UPSTREAM_LIMIT,
709
- * cloudflare: typeof DEFAULT_CLOUDFLARE,
710
- * prewarm: Record<string, unknown>,
711
- * prewarmPriority: ResolvedConfig["prewarmPriority"] }}
712
- */
713
- function normalizeCache(raw) {
714
- /** @type {ResolvedConfig["html"]} */
715
- const html = [];
716
-
717
- for (const [source, seconds] of Object.entries(raw?.html ?? {})) {
718
- const pattern = compilePattern(source);
719
- const value = Number(seconds);
720
- if (!pattern || !Number.isFinite(value) || value < 0) continue;
721
- html.push({ pattern, seconds: value });
722
- }
723
-
724
- const prewarm = normalizePrewarm(raw?.prewarm);
725
- const queryRules = normalizeQueryRules(raw?.query);
726
- const maxEntries = Number(raw?.maxEntries);
727
- let htmlMaxEntries =
728
- Number.isFinite(maxEntries) && maxEntries > 0
729
- ? Math.floor(maxEntries)
730
- : DEFAULT_HTML_CACHE_MAX_ENTRIES;
731
- if (htmlMaxEntries > HTML_CACHE_MAX_ENTRIES_CEILING) {
732
- console.warn(
733
- `[config] cache().maxEntries ${htmlMaxEntries} exceeds the ceiling of ` +
734
- `${HTML_CACHE_MAX_ENTRIES_CEILING}; using ${HTML_CACHE_MAX_ENTRIES_CEILING}`,
735
- );
736
- htmlMaxEntries = HTML_CACHE_MAX_ENTRIES_CEILING;
737
- }
738
-
739
- return {
740
- html,
741
- cacheQuery: queryRules,
742
- cacheVary: normalizeVary(raw?.vary),
743
- htmlMaxEntries,
744
- data: normalizeDataCache(raw?.data),
745
- // Otomatik upstream izleme kapatılabilir olmalı: `fetch`i kendisi saran
746
- // bir uygulama (ölçüm, retry, circuit breaker) çakışma yaşayabilir.
747
- trackUpstream: raw?.trackUpstream !== false,
748
- // Hangi sayfanın hangi veri anahtarını okuduğu kaydedilsin mi.
749
- // `withDataCache` kullanmayan bir uygulamada kaydedilecek bir şey yok;
750
- // kapatmak bağlam kurma maliyetini de kaldırır.
751
- trackDependencies: raw?.trackDependencies !== false,
752
- transientRetry:
753
- raw?.transientRetry === false
754
- ? { attempts: 0, delayMs: 0 }
755
- : { ...DEFAULT_TRANSIENT_RETRY, ...(raw?.transientRetry ?? {}) },
756
- redis: normalizeRedis(raw?.redis),
757
- upstream: normalizeUpstream(raw?.upstream),
758
- cloudflare: normalizeCloudflare(raw?.cloudflare),
759
- // Desenler derlenmiş hâlde ayrı alanda tutulur: `prewarm` sayısal
760
- // ayarların düz torbası olarak kalsın, her turda yeniden derlenmesin.
761
- prewarm,
762
- prewarmPriority: normalizePriority(prewarm.priority),
763
- };
764
- }
765
-
766
- /**
767
- * İstenen pozitif tamsayı tavanı aşıyorsa uyarı basıp tavana çeker.
768
- *
769
- * @param {string} field
770
- * @param {number} requested
771
- * @param {number} ceiling
772
- * @returns {number}
773
- */
774
- function clampCeiling(field, requested, ceiling) {
775
- if (requested <= ceiling) return requested;
776
- console.warn(
777
- `[config] ${field} ${requested} exceeds the ceiling of ${ceiling}; using ${ceiling}`,
778
- );
779
- return ceiling;
780
- }
781
-
782
- /**
783
- * Veri önbelleği JSON tutar; sınır HTML'den yüksek olabilir ama sonsuz değil.
784
- *
785
- * @param {unknown} raw
786
- * @returns {{ maxEntries: number, staleFactor: number }}
787
- */
788
- function normalizeDataCache(raw) {
789
- const source =
790
- raw && typeof raw === "object" && !Array.isArray(raw)
791
- ? /** @type {Record<string, unknown>} */ (raw)
792
- : {};
793
- const requested = Number(source.maxEntries);
794
- let maxEntries =
795
- Number.isFinite(requested) && requested > 0
796
- ? Math.floor(requested)
797
- : DEFAULT_DATA_CACHE.maxEntries;
798
- maxEntries = clampCeiling(
799
- "cache().data.maxEntries",
800
- maxEntries,
801
- DATA_CACHE_MAX_ENTRIES_CEILING,
802
- );
803
-
804
- const stale = Number(source.staleFactor);
805
- return {
806
- maxEntries,
807
- staleFactor:
808
- Number.isFinite(stale) && stale >= 0 ? stale : DEFAULT_DATA_CACHE.staleFactor,
809
- };
810
- }
811
-
812
- /**
813
- * onVisit sürekli çalışır. Klasik turdaki `rps: 0` (sınırsız) burada her
814
- * ziyaretçide yeniden crawl demek; boş, `0` ve tavanın üstü 2'ye çekilir.
815
- *
816
- * @param {Record<string, unknown>} source
817
- * @returns {{ enabled: boolean } & typeof DEFAULT_PREWARM_ON_VISIT}
818
- */
819
- function resolveOnVisitLimits(source) {
820
- const perPageRaw = Number(source.perPage);
821
- const perPage = clampCeiling(
822
- "cache().prewarm.onVisit.perPage",
823
- Number.isFinite(perPageRaw) && perPageRaw > 0
824
- ? Math.floor(perPageRaw)
825
- : DEFAULT_PREWARM_ON_VISIT.perPage,
826
- ON_VISIT_PER_PAGE_CEILING,
827
- );
828
-
829
- const concurrencyRaw = Number(source.concurrency);
830
- const concurrency = clampCeiling(
831
- "cache().prewarm.onVisit.concurrency",
832
- Number.isFinite(concurrencyRaw) && concurrencyRaw > 0
833
- ? Math.floor(concurrencyRaw)
834
- : ON_VISIT_CONCURRENCY_CEILING,
835
- ON_VISIT_CONCURRENCY_CEILING,
836
- );
837
-
838
- const rpsRaw = Number(source.rps);
839
- let rps = ON_VISIT_RPS_CEILING;
840
- if (source.rps != null && source.rps !== "") {
841
- if (!Number.isFinite(rpsRaw) || rpsRaw <= 0) {
842
- console.warn(
843
- `[config] cache().prewarm.onVisit.rps ${source.rps} is not a positive rate; ` +
844
- `using ${ON_VISIT_RPS_CEILING}`,
845
- );
846
- } else {
847
- rps = clampCeiling(
848
- "cache().prewarm.onVisit.rps",
849
- rpsRaw,
850
- ON_VISIT_RPS_CEILING,
851
- );
852
- }
853
- }
854
-
855
- return {
856
- ...DEFAULT_PREWARM_ON_VISIT,
857
- enabled: source.enabled !== false,
858
- perPage,
859
- concurrency,
860
- rps,
861
- };
862
- }
863
-
864
- /**
865
- * @param {unknown} raw
866
- * @returns {{ enabled: boolean } & typeof DEFAULT_PREWARM_ON_VISIT}
867
- */
868
- function normalizeOnVisit(raw) {
869
- if (raw === true) return resolveOnVisitLimits({});
870
-
871
- if (raw == null || raw === false) {
872
- return { ...DEFAULT_PREWARM_ON_VISIT, enabled: false };
873
- }
874
-
875
- if (typeof raw !== "object" || Array.isArray(raw)) {
876
- throw new Error(
877
- "[config] cache().prewarm.onVisit must be true, false, or an object",
878
- );
879
- }
880
-
881
- return resolveOnVisitLimits(/** @type {Record<string, unknown>} */ (raw));
882
- }
883
-
884
- /**
885
- * Klasik liste ısıtması ile `onVisit` karşılıklı dışlayıcıdır. İkisini birden
886
- * yazmak sessizce yanlış moda düşmesin diye yüklemede hata verir.
887
- *
888
- * @param {unknown} raw
889
- * @returns {Record<string, unknown>}
890
- */
891
- function normalizePrewarm(raw) {
892
- const source =
893
- raw && typeof raw === "object" && !Array.isArray(raw)
894
- ? /** @type {Record<string, unknown>} */ ({ ...raw })
895
- : {};
896
-
897
- const onVisit = normalizeOnVisit(source.onVisit);
898
-
899
- if (onVisit.enabled) {
900
- const conflicts = Object.keys(source).filter(
901
- (key) => key !== "onVisit" && CLASSIC_PREWARM_KEYS.includes(key),
902
- );
903
- if (conflicts.length) {
904
- throw new Error(
905
- "[config] cache().prewarm.onVisit cannot be combined with classic " +
906
- `prewarm settings (${conflicts.join(", ")}). Use either onVisit or ` +
907
- "classic settings (max, priority, rotate, …), not both.",
908
- );
909
- }
910
-
911
- const unknown = Object.keys(source).filter((key) => key !== "onVisit");
912
- if (unknown.length) {
913
- throw new Error(
914
- "[config] cache().prewarm.onVisit cannot be combined with " +
915
- `${unknown.join(", ")}. On-visit mode only accepts the onVisit object.`,
916
- );
917
- }
918
-
919
- return {
920
- ...DEFAULT_PREWARM,
921
- enabled: true,
922
- onVisit,
923
- };
924
- }
925
-
926
- // Klasik mod: `onVisit: false` yazılmış olabilir; diğer alanlar varsayılanlarla
927
- // birleşir. `onVisit` anahtarı çözülmüş nesnede her zaman durur.
928
- const classic = { ...source };
929
- delete classic.onVisit;
930
-
931
- const origins = asArray(classic.origins, "cache().prewarm.origins")
932
- .filter((value) => typeof value === "string" && /^https?:\/\//i.test(value))
933
- .map(String);
934
- classic.origins = origins;
935
-
936
- return {
937
- ...DEFAULT_PREWARM,
938
- ...classic,
939
- onVisit,
940
- };
941
- }
942
-
943
- /** Speculation Rules'un tanıdığı eagerness değerleri. */
944
- const EAGERNESS = new Set(["conservative", "moderate", "eager"]);
945
-
946
- /**
947
- * `true` → varsayılan eagerness, `false` → kapalı, string → doğrulanır.
948
- * Geçersiz bir değer siteyi düşürmemeli; uyarı basılıp varsayılana dönülür.
949
- *
950
- * @param {unknown} value
951
- * @param {false | Eagerness} fallback
952
- * @param {string} label
953
- * @returns {false | Eagerness}
954
- */
955
- function normalizeEagerness(value, fallback, label) {
956
- if (value === undefined) return fallback;
957
- if (value === false) return false;
958
- if (value === true) return fallback === false ? "moderate" : fallback;
959
- if (typeof value === "string" && EAGERNESS.has(value)) {
960
- return /** @type {Eagerness} */ (value);
961
- }
962
-
963
- console.warn(
964
- `[config] navigation.${label} is invalid (${String(value)}), falling back to the default`,
965
- );
966
- return fallback;
967
- }
968
-
969
- /**
970
- * @param {unknown} raw
971
- * @param {Record<string, unknown>} brand
972
- * @returns {NavigationConfig}
973
- */
974
- function normalizeNavigation(raw, brand) {
975
- const source = /** @type {Record<string, unknown>} */ (raw ?? {});
976
-
977
- // Dev araçlarının yolu spekülasyona kapalı: overlay ve rapor uçları gerçek
978
- // sayfa değil, önden getirilmelerinin hiçbir karşılığı yok.
979
- const devBase = typeof brand.devBasePath === "string" ? brand.devBasePath : null;
980
-
981
- return {
982
- prefetch: normalizeEagerness(
983
- source.prefetch,
984
- DEFAULT_NAVIGATION.prefetch,
985
- "prefetch",
986
- ),
987
- prerender: normalizeEagerness(
988
- source.prerender,
989
- DEFAULT_NAVIGATION.prerender,
990
- "prerender",
991
- ),
992
- viewTransition: source.viewTransition === true,
993
- exclude: [
994
- ...DEFAULT_NAVIGATION_EXCLUDE,
995
- ...(devBase ? [`${devBase}/*`] : []),
996
- ...asArray(source.exclude, "navigation.exclude").filter(
997
- (entry) => typeof entry === "string",
998
- ),
999
- ].map(String),
1000
- };
1001
- }
1002
-
1003
- /**
1004
- * @typedef {object} SecurityConfig
1005
- * @property {boolean} trustProxy
1006
- * @property {string | null} cookieSecret
1007
- * @property {{ enabled: boolean, token: boolean, allowedOrigins: string[],
1008
- * exclude: CompiledPattern[], cookieName: string, fieldName: string,
1009
- * headerName: string }} csrf
1010
- */
1011
-
1012
- /**
1013
- * @param {unknown} raw
1014
- * @returns {Record<string, unknown>}
1015
- */
1016
- function normalizeBrand(raw) {
1017
- const source = /** @type {Record<string, unknown>} */ (raw ?? {});
1018
- const roots = asArray(
1019
- source.sharedCookieRoots ?? DEFAULT_BRAND.sharedCookieRoots,
1020
- "brand.sharedCookieRoots",
1021
- )
1022
- .filter((entry) => typeof entry === "string")
1023
- .map((entry) => {
1024
- const trimmed = String(entry).trim().toLowerCase();
1025
- if (!trimmed) return null;
1026
- return trimmed.startsWith(".") ? trimmed : `.${trimmed}`;
1027
- })
1028
- .filter((entry) => entry !== null);
1029
-
1030
- return {
1031
- ...DEFAULT_BRAND,
1032
- ...source,
1033
- sharedCookieRoots: roots,
1034
- };
1035
- }
1036
-
1037
- /**
1038
- * @param {unknown} raw
1039
- * @returns {{ crossSubdomainHandoff: boolean | Record<string, unknown> }}
1040
- */
1041
- function normalizeAuth(raw) {
1042
- if (raw == null || typeof raw !== "object" || Array.isArray(raw)) {
1043
- return { ...DEFAULT_AUTH };
1044
- }
1045
-
1046
- const source = /** @type {Record<string, unknown>} */ (raw);
1047
- const handoff = source.crossSubdomainHandoff;
1048
-
1049
- if (handoff === true || handoff === false || handoff == null) {
1050
- return {
1051
- crossSubdomainHandoff: handoff === true,
1052
- };
1053
- }
1054
-
1055
- if (typeof handoff === "object" && !Array.isArray(handoff)) {
1056
- return { crossSubdomainHandoff: { ...handoff } };
1057
- }
1058
-
1059
- console.warn(
1060
- "[config] auth.crossSubdomainHandoff must be boolean or object, ignoring it",
1061
- );
1062
- return { ...DEFAULT_AUTH };
1063
- }
1064
-
1065
- /**
1066
- * Güvenlik bölümü. `csrf.exclude` desenleri burada derlenir: her istekte
1067
- * yeniden derlemek gereksiz, ve bozuk bir desen sunucuyu düşürmemeli.
1068
- *
1069
- * @param {unknown} raw
1070
- * @returns {SecurityConfig}
1071
- */
1072
- function normalizeSecurity(raw) {
1073
- const source = /** @type {Record<string, any>} */ (raw ?? {});
1074
- const csrf = { ...DEFAULT_SECURITY.csrf, ...(source.csrf ?? {}) };
1075
-
1076
- const exclude = asArray(csrf.exclude, "security.csrf.exclude")
1077
- .map((entry) => compilePattern(entry))
1078
- .filter((pattern) => pattern !== null);
1079
-
1080
- return {
1081
- trustProxy: source.trustProxy !== false,
1082
- cookieSecret:
1083
- typeof source.cookieSecret === "string" && source.cookieSecret
1084
- ? source.cookieSecret
1085
- : null,
1086
- csrf: {
1087
- enabled: csrf.enabled !== false,
1088
- token: csrf.token === true,
1089
- allowedOrigins: asArray(csrf.allowedOrigins, "security.csrf.allowedOrigins")
1090
- .filter((entry) => typeof entry === "string")
1091
- .map(String),
1092
- exclude: /** @type {CompiledPattern[]} */ (exclude),
1093
- cookieName: String(csrf.cookieName ?? DEFAULT_SECURITY.csrf.cookieName),
1094
- fieldName: String(csrf.fieldName ?? DEFAULT_SECURITY.csrf.fieldName),
1095
- headerName: String(csrf.headerName ?? DEFAULT_SECURITY.csrf.headerName).toLowerCase(),
1096
- },
1097
- };
1098
- }
1099
-
1100
- /**
1101
- * İkon sprite ayarları. `false` → adım atlanır. `dir` varsayılanı `"icons"`:
1102
- * o dizin varsa yalnızca yerel SVG'ler; yoksa Phosphor.
1103
- *
1104
- * @param {unknown} raw
1105
- * @returns {{ scan?: string[], dir: string } | false}
1106
- */
1107
- function normalizeIcons(raw) {
1108
- if (raw === false) return false;
1109
-
1110
- const source = /** @type {Record<string, any>} */ (raw ?? {});
1111
- const dir =
1112
- typeof source.dir === "string" && source.dir.trim()
1113
- ? source.dir.trim()
1114
- : "icons";
1115
-
1116
- /** @type {{ scan?: string[], dir: string }} */
1117
- const icons = { dir };
1118
-
1119
- if (source.scan != null) {
1120
- icons.scan = asArray(source.scan, "icons.scan")
1121
- .filter((entry) => typeof entry === "string" && entry.trim())
1122
- .map((entry) => String(entry).trim());
1123
- }
1124
-
1125
- return icons;
1126
- }
1127
-
1128
- /**
1129
- * Build + runtime görsel ayarları. `false` → her iki yüzey de kapalı.
1130
- * `remote.allowHosts` boşsa remote kapalı kalır (açık proxy olmasın).
1131
- *
1132
- * @param {unknown} raw
1133
- * @returns {ImagesConfig | false}
1134
- */
1135
- function normalizeImages(raw) {
1136
- if (raw === false) return false;
1137
-
1138
- const source = /** @type {Record<string, any>} */ (raw ?? {});
1139
- const widths = asArray(source.widths ?? DEFAULT_IMAGES.widths, "images.widths")
1140
- .map((entry) => Number(entry))
1141
- .filter((entry) => Number.isFinite(entry) && entry > 0)
1142
- .map((entry) => Math.round(entry));
1143
-
1144
- const quality = Number(source.quality ?? DEFAULT_IMAGES.quality);
1145
- const skip = asArray(source.skip ?? DEFAULT_IMAGES.skip, "images.skip")
1146
- .filter((entry) => typeof entry === "string")
1147
- .map(String);
1148
-
1149
- /** @type {ImagesRemoteConfig | false} */
1150
- let remote = false;
1151
- if (source.remote !== false && source.remote != null) {
1152
- const rem = /** @type {Record<string, any>} */ (
1153
- source.remote === true ? {} : source.remote
1154
- );
1155
- const allowHosts = asArray(
1156
- rem.allowHosts ?? DEFAULT_IMAGES.remote.allowHosts,
1157
- "images.remote.allowHosts",
1158
- )
1159
- .filter((entry) => typeof entry === "string" && entry.trim())
1160
- .map((entry) => String(entry).trim().toLowerCase());
1161
-
1162
- if (allowHosts.length === 0) {
1163
- if (source.remote === true || rem.allowHosts != null) {
1164
- console.warn(
1165
- "[config] images.remote needs a non-empty allowHosts list; remote optimizer disabled",
1166
- );
1167
- }
1168
- } else {
1169
- remote = {
1170
- enabled: true,
1171
- allowHosts,
1172
- path: String(rem.path ?? DEFAULT_IMAGES.remote.path),
1173
- maxWidth: Math.max(
1174
- 1,
1175
- Number(rem.maxWidth ?? DEFAULT_IMAGES.remote.maxWidth) ||
1176
- DEFAULT_IMAGES.remote.maxWidth,
1177
- ),
1178
- cacheMaxAge: Math.max(
1179
- 0,
1180
- Number(rem.cacheMaxAge ?? DEFAULT_IMAGES.remote.cacheMaxAge) ||
1181
- DEFAULT_IMAGES.remote.cacheMaxAge,
1182
- ),
1183
- fetchTimeoutMs: Math.max(
1184
- 1000,
1185
- Number(rem.fetchTimeoutMs ?? DEFAULT_IMAGES.remote.fetchTimeoutMs) ||
1186
- DEFAULT_IMAGES.remote.fetchTimeoutMs,
1187
- ),
1188
- maxBytes: Math.max(
1189
- 1024,
1190
- Number(rem.maxBytes ?? DEFAULT_IMAGES.remote.maxBytes) ||
1191
- DEFAULT_IMAGES.remote.maxBytes,
1192
- ),
1193
- };
1194
- }
1195
- }
1196
-
1197
- return {
1198
- widths: widths.length ? widths : [...DEFAULT_IMAGES.widths],
1199
- quality: Number.isFinite(quality) && quality > 0 ? quality : DEFAULT_IMAGES.quality,
1200
- skip,
1201
- remote,
1202
- };
1203
- }
1204
-
1205
- /**
1206
- * Dizin adlarını mutlak yola çevirir. `styles` bir dosya yolu olduğu için
1207
- * de aynı çözümlemeden geçer; ayrı bir alan tutmaya değmez.
1208
- *
1209
- * @param {string} root
1210
- * @param {Record<string, string>} [overrides]
1211
- * @returns {Record<string, string>}
1212
- */
1213
- function resolveDirs(root, overrides) {
1214
- /** @type {Record<string, string>} */
1215
- const dirs = {};
1216
- const merged = { ...DEFAULT_DIRS, ...(overrides ?? {}) };
1217
-
1218
- for (const [key, value] of Object.entries(merged)) {
1219
- dirs[key] = path.resolve(root, value);
1220
- }
1221
-
1222
- // Build çıktısı `public/assets` altına yazılır; ayrı ayar gerektirmeyecek
1223
- // kadar sabit ama yol hesabı tek yerde kalsın.
1224
- dirs.assets = path.join(dirs.public, "assets");
1225
- dirs.fonts = path.join(dirs.public, "fonts");
1226
-
1227
- return dirs;
1228
- }
1229
-
1230
- /**
1231
- * Uygulamanın layout'u yoksa framework'ün minimal layout'u kullanılır. Bu
1232
- * sayede yeni bir proje tek bir route ile çalışır hâle gelir.
1233
- *
1234
- * Öncelik: config `layout` → `layout.jsk` (derlenmiş) → `layout.ejs` →
1235
- * framework varsayılanı. Dönüş değeri kaynak dosya yoludur; `.jsk` için
1236
- * render katmanı derlenmiş modülü kullanır.
1237
- *
1238
- * @param {Record<string, string>} dirs
1239
- * @param {string} [override]
1240
- * @returns {string}
1241
- */
1242
- function resolveLayout(dirs, override) {
1243
- if (override) return path.resolve(dirs.views, "..", override);
1244
-
1245
- const jskLayout = path.join(dirs.views, "layout.jsk");
1246
- if (fs.existsSync(jskLayout)) return jskLayout;
1247
-
1248
- const appLayout = path.join(dirs.views, "layout.ejs");
1249
- if (fs.existsSync(appLayout)) return appLayout;
1250
-
1251
- return path.join(FRAMEWORK_ROOT, "src", "templates", "layout.jsk");
1252
- }
1253
-
1254
- /**
1255
- * Config'i okur, normalize eder ve modül durumuna yazar. Sunucu ve build
1256
- * süreçleri açılışta bir kez çağırır.
1257
- *
1258
- * Aynı süreçte ikinci çağrı önbelleğe düşer: `jskelet start` hem
1259
- * `ensure-build` hem `createApp` üzerinden çağırıyor ve config'i iki kez
1260
- * okuyup iki kez loglamanın hiçbir faydası yok. Yeniden okumak gerekiyorsa
1261
- * `force: true`.
1262
- *
1263
- * @param {{ root?: string, configFile?: string, force?: boolean }} [options]
1264
- * @returns {Promise<ResolvedConfig>}
1265
- */
1266
- export async function loadConfig(options = {}) {
1267
- if (config && !options.force) return config;
1268
-
1269
- const root = path.resolve(options.root ?? process.cwd());
1270
- const configFile = options.configFile ?? CONFIG_FILE;
1271
- const configPath = path.join(root, configFile);
1272
-
1273
- /** @type {Record<string, any>} */
1274
- let source = {};
1275
- let loaded = false;
1276
-
1277
- if (!fs.existsSync(configPath)) {
1278
- console.warn(
1279
- `[config] ${configFile} not found — continuing with built-in defaults.`,
1280
- );
1281
- } else {
1282
- try {
1283
- // Windows'ta mutlak yol import'u için file:// şeması gerekir.
1284
- const module = await import(pathToFileURL(configPath).href);
1285
- source = module.default ?? module;
1286
- loaded = true;
1287
- } catch (error) {
1288
- console.warn(`[config] ${configFile} failed to load, ignoring it`, error);
1289
- }
1290
- }
1291
-
1292
- /** @param {string} name */
1293
- const section = async (name) => {
1294
- const value = source?.[name];
1295
- if (value == null) return null;
1296
- try {
1297
- return typeof value === "function" ? await value.call(source) : value;
1298
- } catch (error) {
1299
- console.warn(`[config] ${name}() threw, ignoring it`, error);
1300
- return null;
1301
- }
1302
- };
1303
-
1304
- const [headers, redirects, rewrites, cache, admin, logs] = await Promise.all([
1305
- section("headers"),
1306
- section("redirects"),
1307
- section("rewrites"),
1308
- section("cache"),
1309
- section("admin"),
1310
- section("logs"),
1311
- ]);
1312
-
1313
- const {
1314
- html,
1315
- cacheQuery,
1316
- cacheVary,
1317
- htmlMaxEntries,
1318
- data,
1319
- trackUpstream,
1320
- trackDependencies,
1321
- transientRetry,
1322
- redis,
1323
- upstream,
1324
- cloudflare,
1325
- prewarm,
1326
- prewarmPriority,
1327
- } = normalizeCache(cache);
1328
- const dirs = resolveDirs(root, source.paths);
1329
- const brand = normalizeBrand(source.brand);
1330
- const auth = normalizeAuth(source.auth);
1331
-
1332
- config = {
1333
- root,
1334
- loaded,
1335
- dirs,
1336
- headers: normalizeHeaders(headers),
1337
- redirects: normalizeRedirects(redirects),
1338
- rewrites: normalizeRewrites(rewrites),
1339
- html,
1340
- cacheQuery,
1341
- cacheVary,
1342
- htmlMaxEntries,
1343
- data,
1344
- trackUpstream,
1345
- trackDependencies,
1346
- transientRetry,
1347
- redis,
1348
- upstream,
1349
- logs: normalizeLogs(logs),
1350
- admin: normalizeAdmin(admin),
1351
- cloudflare,
1352
- prewarm,
1353
- prewarmPriority,
1354
- brand,
1355
- auth,
1356
- hooks: source.hooks ?? {},
1357
- layout: resolveLayout(dirs, source.layout),
1358
- routes: Array.isArray(source.routes) ? source.routes : null,
1359
- // Varsayılan kapalı: açıkken `/hakkinda` → 308 `/hakkinda/` ve kanonik
1360
- // yanıt 200'dir. Kapalıyken slash dayatılmaz — Express'in non-strict
1361
- // eşleşmesi her iki biçimi de 200 ile servis eder (Next'in varsayılan
1362
- // "slash'ı kırp" davranışından bilinçli fark).
1363
- trailingSlash: source.trailingSlash === true,
1364
- static: {
1365
- extensions: new Set(source.static?.extensions ?? DEFAULT_STATIC.extensions),
1366
- prefixes: source.static?.prefixes ?? DEFAULT_STATIC.prefixes,
1367
- },
1368
- devGate: normalizeDevGate(source),
1369
- devGateBypass: source.devGateBypass ?? DEFAULT_DEV_GATE_BYPASS,
1370
- preconnect: source.preconnect ?? [],
1371
- navigation: normalizeNavigation(source.navigation, brand),
1372
- security: normalizeSecurity(source.security),
1373
- prewarmSkip: source.prewarmSkip ?? DEFAULT_PREWARM_SKIP,
1374
- // `routes`, `views` ve `lib` zaten izlenir; buraya yalnızca ek dizinler.
1375
- watch: source.watch ?? [],
1376
- // Build tarafı ayarları. Sunucu bunları okumaz ama config tek dosya
1377
- // olsun diye aynı yerden geçer.
1378
- fonts: source.fonts ?? [],
1379
- icons: normalizeIcons(source.icons),
1380
- images: normalizeImages(source.images),
1381
- clientEnv: source.clientEnv ?? [],
1382
- };
1383
-
1384
- if (
1385
- config.prewarm?.onVisit?.enabled &&
1386
- typeof config.hooks?.prewarmPaths === "function"
1387
- ) {
1388
- throw new Error(
1389
- "[config] hooks.prewarmPaths() cannot be used with cache().prewarm.onVisit. " +
1390
- "On-visit mode warms links from each response; classic mode uses prewarmPaths. " +
1391
- "Choose one.",
1392
- );
1393
- }
1394
-
1395
- // Dev'de build ve sunucu ayrı alt süreçler; üçü de aynı özeti basınca satır
1396
- // banner'ın ve build bloğunun arasına üç kez giriyor. Özeti dış süreç basar.
1397
- if (loaded && !process.env.JSKELET_CHILD) {
1398
- /** @param {number} count @param {string} singular @param {string} plural */
1399
- const label = (count, singular, plural) =>
1400
- `${count} ${count === 1 ? singular : plural}`;
1401
-
1402
- const counts = [
1403
- config.headers.length && label(config.headers.length, "header", "headers"),
1404
- config.redirects.length &&
1405
- label(config.redirects.length, "redirect", "redirects"),
1406
- config.rewrites.length && label(config.rewrites.length, "rewrite", "rewrites"),
1407
- config.html.length && label(config.html.length, "cache rule", "cache rules"),
1408
- ].filter(Boolean);
1409
-
1410
- if (counts.length) {
1411
- console.log(`[config] ${configFile} loaded — ${counts.join(", ")}`);
1412
- }
1413
- }
1414
-
1415
- return config;
1416
- }
1417
-
1418
- /**
1419
- * Çözümlenmiş config. `loadConfig()` çağrılmadan erişilirse boş bir proje
1420
- * kökü varsayımıyla çalışmak yerine hata verir: sessiz yanlış yol,
1421
- * "stylesheet neden yok" gibi teşhisi zor sorunlara dönüşüyor.
1422
- *
1423
- * @returns {ResolvedConfig}
1424
- */
1425
- export function getConfig() {
1426
- if (!config) {
1427
- throw new Error(
1428
- "[config] getConfig() was used before loadConfig(). " +
1429
- "Start the server with the `jskelet` CLI or through createApp().",
1430
- );
1431
- }
1432
- return config;
1433
- }
1434
-
1435
- /**
1436
- * Uygulamanın tanımladığı hook'u çalıştırır; yoksa `fallback` döner.
1437
- * Hook'un hata vermesi sayfayı düşürmemeli — framework kendi varsayılanına
1438
- * geri döner ve uyarır.
1439
- *
1440
- * @template T
1441
- * @param {string} name
1442
- * @param {T} fallback
1443
- * @param {unknown[]} args
1444
- * @returns {Promise<T>}
1445
- */
1446
- export async function hook(name, fallback, ...args) {
1447
- const fn = getConfig().hooks?.[name];
1448
- if (typeof fn !== "function") return fallback;
1449
-
1450
- try {
1451
- return await fn(...args);
1452
- } catch (error) {
1453
- console.warn(`[config] hooks.${name}() threw, using the default`, error);
1454
- return fallback;
1455
- }
1456
- }
1
+ /**
2
+ * `jskelet.config.mjs` yükleyicisi ve çözümlenmiş proje durumu.
3
+ *
4
+ * Bu modül framework'ün **tek gerçek kaynağıdır**: proje kökü, dizin yolları,
5
+ * markalama, hook'lar ve `headers/redirects/rewrites/cache` kuralları burada
6
+ * normalize edilir. Diğer modüller yol hesaplamaz, `getConfig()` çağırır.
7
+ * Böylece framework `node_modules/` içine girdiğinde hiçbir dosyada
8
+ * `../..` sayma hatası oluşmaz.
9
+ *
10
+ * Config dosyası **zorunlu değildir**: yoksa ya da okunamıyorsa uyarı basılır
11
+ * ve sunucu varsayılanlarla ayağa kalkar. Bozuk bir düzenleme siteyi
12
+ * açılamaz hâle getirmemeli.
13
+ *
14
+ * Desteklenen bölümler (hepsi opsiyonel, hepsi `async` olabilir):
15
+ * headers() → [{ source, headers: [{ key, value }] }]
16
+ * redirects() → [{ source, destination, permanent?, statusCode? }]
17
+ * rewrites() → [{ source, destination }] | { beforeFiles?, afterFiles? }
18
+ * cache() → { html?: { [source]: saniye },
19
+ * staleWhileRevalidate?: number,
20
+ * query?: { [source]: string[] | true },
21
+ * vary?: { host?: boolean, headers?: string[], fn?: Function },
22
+ * maxEntries?: number,
23
+ * data?: {...}, redis?: {...}, prewarm?: {...} }
24
+ * admin() → { enabled?, basePath?, allowIps?, blockBots?, … }
25
+ * auth → { crossSubdomainHandoff?: boolean | object }
26
+ * logs → { console?, kinds?, file?, s3? }
27
+ *
28
+ * Fonksiyon olmayan bölümler (`brand`, `security`, `static`, `navigation`…)
29
+ * düz nesne olarak okunur. `logs` fonksiyon ya da düz nesne olabilir.
30
+ */
31
+ import fs from "node:fs";
32
+ import path from "node:path";
33
+ import process from "node:process";
34
+ import { pathToFileURL } from "node:url";
35
+ import { compilePattern, matchPattern } from "./pattern.js";
36
+ import {
37
+ DEFAULT_ADMIN,
38
+ DEFAULT_AUTH,
39
+ DEFAULT_BRAND,
40
+ DEFAULT_CLOUDFLARE,
41
+ DATA_CACHE_MAX_ENTRIES_CEILING,
42
+ DEFAULT_DATA_CACHE,
43
+ DEFAULT_DEV_GATE_BYPASS,
44
+ DEFAULT_DIRS,
45
+ DEFAULT_HTML_CACHE_MAX_ENTRIES,
46
+ HTML_CACHE_MAX_ENTRIES_CEILING,
47
+ ON_VISIT_CONCURRENCY_CEILING,
48
+ ON_VISIT_PER_PAGE_CEILING,
49
+ ON_VISIT_RPS_CEILING,
50
+ DEFAULT_IMAGES,
51
+ DEFAULT_LOGS,
52
+ DEFAULT_NAVIGATION,
53
+ DEFAULT_NAVIGATION_EXCLUDE,
54
+ DEFAULT_PREWARM,
55
+ DEFAULT_PREWARM_ON_VISIT,
56
+ DEFAULT_PREWARM_SKIP,
57
+ CLASSIC_PREWARM_KEYS,
58
+ DEFAULT_REDIS,
59
+ DEFAULT_SECURITY,
60
+ DEFAULT_STALE_WHILE_REVALIDATE,
61
+ DEFAULT_STATIC,
62
+ DEFAULT_TRANSIENT_RETRY,
63
+ DEFAULT_UPSTREAM_LIMIT,
64
+ } from "./defaults.js";
65
+
66
+ /** Framework paketinin kökü — kendi şablonlarına ve varlıklarına erişir. */
67
+ export const FRAMEWORK_ROOT = path.resolve(import.meta.dirname, "..", "..");
68
+
69
+ const CONFIG_FILE = "jskelet.config.mjs";
70
+
71
+ /**
72
+ * @typedef {"conservative" | "moderate" | "eager"} Eagerness
73
+ *
74
+ * @typedef {object} NavigationConfig
75
+ * @property {false | Eagerness} prefetch
76
+ * @property {false | Eagerness} prerender
77
+ * @property {boolean} viewTransition
78
+ * @property {string[]} exclude Spekülasyon dışı bırakılan href desenleri.
79
+ */
80
+
81
+ /**
82
+ * @typedef {object} RedisConfig
83
+ * @property {boolean} enabled
84
+ * @property {string | null} url
85
+ * @property {string} namespace
86
+ * @property {string} keyPrefix
87
+ * @property {boolean} html HTML gövdeleri paylaşılsın mı.
88
+ * @property {boolean} data Veri önbelleği paylaşılsın mı.
89
+ * @property {boolean} storeEncoded Sıkıştırılmış gövdeler de paylaşılsın mı.
90
+ * @property {boolean} events pub/sub invalidation yayını.
91
+ * @property {number} commandTimeoutMs
92
+ */
93
+
94
+ /**
95
+ * @typedef {"http" | "event" | "error"} LogKind
96
+ *
97
+ * @typedef {object} LogsConfig
98
+ * @property {boolean} console Runtime http/event/error satırları stdout'a
99
+ * basılsın mı (banner/build satırları etkilenmez).
100
+ * @property {LogKind[]} kinds Sink'lere giden kayıt türleri.
101
+ * @property {{ enabled: boolean, dir: string, rotate: "daily" }} file
102
+ * `rotate` durur; dosya parçaları en fazla 5 dakika tutulur.
103
+ * @property {import('../server/logs/file-sink.js').DrainLog | null} drainLog
104
+ * Mühürlenen zstd parçasını uygulamanın seçtiği yere aktarır. Hata
105
+ * siteyi düşürmez.
106
+ * @property {{ enabled: boolean, bucket: string | null, prefix: string,
107
+ * region: string | null, endpoint: string | null, flushIntervalMs: number,
108
+ * maxBatch: number }} s3
109
+ */
110
+
111
+ /**
112
+ * @typedef {import('./pattern.js').CompiledPattern} CompiledPattern
113
+ *
114
+ * @typedef {object} ResolvedConfig
115
+ * @property {string} root Proje kökü (mutlak).
116
+ * @property {boolean} loaded Config dosyası okundu mu.
117
+ * @property {Record<string, string>} dirs Mutlak dizin yolları.
118
+ * @property {{ pattern: CompiledPattern, headers: { key: string, value: string }[] }[]} headers
119
+ * @property {{ pattern: CompiledPattern, destination: string, statusCode: number }[]} redirects
120
+ * @property {{ phase: "beforeFiles" | "afterFiles", pattern: CompiledPattern, destination: string }[]} rewrites
121
+ * @property {{ pattern: CompiledPattern, seconds: number }[]} html
122
+ * @property {{ pattern: CompiledPattern, allow: true | string[] }[]} cacheQuery
123
+ * Yol deseni başına, HTML cache anahtarına girmesine izin verilen query
124
+ * parametreleri. Eşleşen kural yoksa query'li istek cache'lenmez.
125
+ * @property {{ host: boolean, headers: string[],
126
+ * fn: ((req: import('express').Request) => string | null | undefined) | null }} cacheVary
127
+ * Anahtara eklenen sabit parçalar (query allowlist'ten bağımsız). Host'tan
128
+ * locale üreten sitelerde `host: true` zorunlu.
129
+ * @property {number} htmlMaxEntries HTML önbelleğinin girdi sınırı.
130
+ * @property {number} staleWhileRevalidate Edge taze penceresi bittikten sonra
131
+ * eski HTML'in sunulacağı süre (saniye). 0 ise direktif basılmaz. Süreç içi
132
+ * HTML cache'in stale penceresinden bağımsızdır.
133
+ * @property {Record<string, unknown>} data Upstream veri önbelleği ayarları.
134
+ * @property {boolean} trackUpstream `fetch` sarılıp geçici hatalar otomatik bildirilsin mi.
135
+ * @property {boolean} trackDependencies Render'ın okuduğu veri anahtarları kaydedilsin mi.
136
+ * @property {{ attempts: number, delayMs: number }} transientRetry
137
+ * @property {RedisConfig} redis Opsiyonel Redis ikinci kademesi.
138
+ * @property {typeof DEFAULT_UPSTREAM_LIMIT} upstream Upstream hız freni.
139
+ * @property {LogsConfig} logs Kalıcı log sink'leri (dosya + S3).
140
+ * @property {typeof DEFAULT_ADMIN} admin Framework yönetim paneli.
141
+ * @property {typeof DEFAULT_CLOUDFLARE} cloudflare Cloudflare cache yüzeyi.
142
+ * @property {Record<string, unknown>} prewarm
143
+ * @property {{ source: string, test: (pathname: string) => boolean }[]} prewarmPriority
144
+ * @property {Record<string, unknown>} brand
145
+ * @property {{ crossSubdomainHandoff: boolean | Record<string, unknown> }} auth
146
+ * @property {Record<string, Function>} hooks
147
+ * @property {string} layout Layout `.ejs` dosyasının mutlak yolu.
148
+ * @property {string[] | null} routes Açık route modülü listesi.
149
+ * @property {boolean} trailingSlash URL'ler `/` ile bitsin mi (Next `trailingSlash`).
150
+ * @property {{ extensions: Set<string>, prefixes: string[] }} static
151
+ * @property {boolean} devGate `DEV_TOKEN` tek başına siteyi kilitlemez; gate
152
+ * ancak bu bayrak veya `DEV_GATE=1` ile açılır.
153
+ * @property {string[]} devGateBypass
154
+ * @property {string[]} preconnect
155
+ * @property {NavigationConfig} navigation
156
+ * @property {SecurityConfig} security
157
+ * @property {string[]} prewarmSkip
158
+ * @property {string[]} watch Dev sunucusunun izlediği ek dizinler.
159
+ * @property {{ family: string, slug?: string, weights: number[] }[]} fonts
160
+ * @property {{ scan?: string[], dir: string } | false} icons
161
+ * @property {ImagesConfig | false} images
162
+ * @property {string[]} clientEnv Client bundle'a gömülecek env anahtarları.
163
+ */
164
+
165
+ /**
166
+ * @typedef {object} ImagesRemoteConfig
167
+ * @property {boolean} enabled
168
+ * @property {string[]} allowHosts
169
+ * @property {string} path
170
+ * @property {number} maxWidth
171
+ * @property {number} cacheMaxAge
172
+ * @property {number} fetchTimeoutMs
173
+ * @property {number} maxBytes
174
+ */
175
+
176
+ /**
177
+ * @typedef {object} ImagesConfig
178
+ * @property {number[]} widths
179
+ * @property {number} quality
180
+ * @property {string[]} skip
181
+ * @property {ImagesRemoteConfig | false} remote
182
+ */
183
+
184
+ /** @type {ResolvedConfig | null} */
185
+ let config = null;
186
+
187
+ /**
188
+ * @param {unknown} value
189
+ * @param {string} label
190
+ * @returns {unknown[]}
191
+ */
192
+ function asArray(value, label) {
193
+ if (value == null) return [];
194
+ if (Array.isArray(value)) return value;
195
+ console.warn(`[config] ${label} must return an array, ignoring it`);
196
+ return [];
197
+ }
198
+
199
+ /**
200
+ * @param {unknown} raw
201
+ * @returns {ResolvedConfig["headers"]}
202
+ */
203
+ function normalizeHeaders(raw) {
204
+ /** @type {ResolvedConfig["headers"]} */
205
+ const out = [];
206
+
207
+ for (const entry of asArray(raw, "headers()")) {
208
+ const pattern = compilePattern(entry?.source);
209
+ if (!pattern) continue;
210
+
211
+ const headers = asArray(entry?.headers, "headers()[].headers")
212
+ .filter((header) => header?.key && header?.value !== undefined)
213
+ .map((header) => ({ key: String(header.key), value: String(header.value) }));
214
+
215
+ if (headers.length) out.push({ pattern, headers });
216
+ }
217
+
218
+ return out;
219
+ }
220
+
221
+ /**
222
+ * @param {unknown} raw
223
+ * @returns {ResolvedConfig["redirects"]}
224
+ */
225
+ function normalizeRedirects(raw) {
226
+ /** @type {ResolvedConfig["redirects"]} */
227
+ const out = [];
228
+
229
+ for (const entry of asArray(raw, "redirects()")) {
230
+ const pattern = compilePattern(entry?.source);
231
+ if (!pattern || typeof entry?.destination !== "string") continue;
232
+
233
+ // Next semantiği: permanent → 308, geçici → 307. Farklı bir kod isteyen
234
+ // `statusCode` verebilir (ör. eski kurulumlarla uyum için 301).
235
+ const statusCode = Number(entry.statusCode) || (entry.permanent ? 308 : 307);
236
+
237
+ out.push({ pattern, destination: entry.destination, statusCode });
238
+ }
239
+
240
+ return out;
241
+ }
242
+
243
+ /**
244
+ * @param {unknown} raw
245
+ * @returns {ResolvedConfig["rewrites"]}
246
+ */
247
+ function normalizeRewrites(raw) {
248
+ /** @type {ResolvedConfig["rewrites"]} */
249
+ const out = [];
250
+
251
+ /** @type {[("beforeFiles" | "afterFiles"), unknown][]} */
252
+ const phases = Array.isArray(raw)
253
+ ? [["afterFiles", raw]]
254
+ : [
255
+ ["beforeFiles", raw?.beforeFiles],
256
+ ["afterFiles", raw?.afterFiles],
257
+ ];
258
+
259
+ for (const [phase, entries] of phases) {
260
+ for (const entry of asArray(entries, `rewrites().${phase}`)) {
261
+ const pattern = compilePattern(entry?.source);
262
+ if (!pattern || typeof entry?.destination !== "string") continue;
263
+ out.push({ phase, pattern, destination: entry.destination });
264
+ }
265
+ }
266
+
267
+ return out;
268
+ }
269
+
270
+ /**
271
+ * Isıtma sırası desenleri. İki biçim kabul edilir: config'in her yerinde
272
+ * geçerli olan `/haber/:slug` sözdizimi ve doğrudan `RegExp` — ikincisi
273
+ * "sonu `-yorumlar` ile bitenler" gibi desen sözdiziminin karşılamadığı
274
+ * kuralları yazabilmek için.
275
+ *
276
+ * @param {unknown} raw
277
+ * @returns {ResolvedConfig["prewarmPriority"]}
278
+ */
279
+ function normalizePriority(raw) {
280
+ /** @type {ResolvedConfig["prewarmPriority"]} */
281
+ const out = [];
282
+
283
+ for (const entry of asArray(raw, "cache().prewarm.priority")) {
284
+ if (entry instanceof RegExp) {
285
+ out.push({ source: String(entry), test: (pathname) => entry.test(pathname) });
286
+ continue;
287
+ }
288
+
289
+ const pattern = compilePattern(entry);
290
+ if (!pattern) continue;
291
+ out.push({
292
+ source: pattern.source,
293
+ test: (pathname) => matchPattern(pattern, pathname) !== null,
294
+ });
295
+ }
296
+
297
+ return out;
298
+ }
299
+
300
+ /**
301
+ * Redis bölümü. Bozuk bir değer sunucuyu düşürmemeli: her alan tipine
302
+ * zorlanır ve `enabled` yalnızca açıkça `true` verildiğinde açılır.
303
+ *
304
+ * @param {unknown} raw
305
+ * @returns {RedisConfig}
306
+ */
307
+ function normalizeRedis(raw) {
308
+ const source = /** @type {Record<string, any>} */ (raw ?? {});
309
+ const timeout = Number(source.commandTimeoutMs);
310
+
311
+ return {
312
+ enabled: source.enabled === true,
313
+ url: typeof source.url === "string" && source.url ? source.url : null,
314
+ namespace: String(source.namespace ?? DEFAULT_REDIS.namespace),
315
+ keyPrefix: String(source.keyPrefix ?? DEFAULT_REDIS.keyPrefix),
316
+ html: source.html !== false,
317
+ data: source.data !== false,
318
+ storeEncoded: source.storeEncoded === true,
319
+ events: source.events !== false,
320
+ commandTimeoutMs:
321
+ Number.isFinite(timeout) && timeout > 0
322
+ ? Math.floor(timeout)
323
+ : DEFAULT_REDIS.commandTimeoutMs,
324
+ };
325
+ }
326
+
327
+ /**
328
+ * Upstream hız freni. Sayısal alanlar tipine zorlanır; bozuk bir değer freni
329
+ * yanlış ayarlamak yerine varsayılana döner.
330
+ *
331
+ * @param {unknown} raw
332
+ * @returns {typeof DEFAULT_UPSTREAM_LIMIT}
333
+ */
334
+ function normalizeUpstream(raw) {
335
+ const source = /** @type {Record<string, any>} */ (raw ?? {});
336
+ const merged = { ...DEFAULT_UPSTREAM_LIMIT, ...source };
337
+
338
+ /** @param {string} key */
339
+ const positive = (key) => {
340
+ const value = Number(merged[key]);
341
+ return Number.isFinite(value) && value >= 0
342
+ ? value
343
+ : /** @type {any} */ (DEFAULT_UPSTREAM_LIMIT)[key];
344
+ };
345
+
346
+ /** @type {Record<string, Record<string, number>>} */
347
+ const hosts = {};
348
+ for (const [host, override] of Object.entries(merged.hosts ?? {})) {
349
+ if (override && typeof override === "object") hosts[host] = override;
350
+ }
351
+
352
+ return {
353
+ ...merged,
354
+ rate: positive("rate"),
355
+ burst: positive("burst"),
356
+ concurrency: Math.max(1, Math.floor(positive("concurrency"))),
357
+ minRate: positive("minRate"),
358
+ increaseStep: positive("increaseStep"),
359
+ increaseIntervalMs: positive("increaseIntervalMs"),
360
+ decreaseIntervalMs: positive("decreaseIntervalMs"),
361
+ breakerFailures: Math.floor(positive("breakerFailures")),
362
+ breakerCooldownMs: positive("breakerCooldownMs"),
363
+ hosts,
364
+ };
365
+ }
366
+
367
+ /**
368
+ * Dev gate. `DEV_TOKEN` ortamda durması siteyi kilitlemez: paylaşılan bir
369
+ * task tanımı production'a da aynı değişkeni taşır ve herkese 404 olur.
370
+ * Gate ancak `devGate: true` ya da `DEV_GATE=1` ile açılır. `DEV_GATE=0`
371
+ * config'teki açığı da kapatır.
372
+ *
373
+ * @param {Record<string, any>} source
374
+ * @returns {boolean}
375
+ */
376
+ function normalizeDevGate(source) {
377
+ const env = process.env.DEV_GATE;
378
+ if (env === "0" || env === "false") return false;
379
+ if (env === "1" || env === "true") return true;
380
+ return source.devGate === true;
381
+ }
382
+
383
+ /**
384
+ * Yönetim paneli. `enabled` yalnızca açıkça `true` verildiğinde ya da
385
+ * `JSKELET_ADMIN` ortam değişkeni ayarlandığında açılır: paneli yanlışlıkla
386
+ * açmanın bedeli, önbelleği boşaltabilen bir ucu internete koymak.
387
+ *
388
+ * Ortam değişkeni config'in **üstünde** duruyor, çünkü paneli genelde bir
389
+ * arıza sırasında tek seferlik açmak isteniyor ve o an config dosyasını
390
+ * değiştirip yeniden dağıtmak istenmiyor. `JSKELET_ADMIN=0` aynı mantıkla
391
+ * config'te açık olan paneli kapatır.
392
+ *
393
+ * @param {unknown} raw
394
+ * @returns {typeof DEFAULT_ADMIN}
395
+ */
396
+ function normalizeAdmin(raw) {
397
+ const source = /** @type {Record<string, any>} */ (raw ?? {});
398
+ const env = process.env.JSKELET_ADMIN;
399
+
400
+ const basePath =
401
+ typeof source.basePath === "string" && source.basePath.startsWith("/")
402
+ ? source.basePath.replace(/\/+$/, "")
403
+ : DEFAULT_ADMIN.basePath;
404
+
405
+ /** @param {string} key @param {number} min */
406
+ const positive = (key, min) => {
407
+ const value = Number(source[key]);
408
+ return Number.isFinite(value) && value >= min
409
+ ? value
410
+ : /** @type {any} */ (DEFAULT_ADMIN)[key];
411
+ };
412
+
413
+ const allowIps = Array.isArray(source.allowIps)
414
+ ? source.allowIps
415
+ .filter((entry) => typeof entry === "string" && entry.trim())
416
+ .map((entry) => entry.trim())
417
+ : [...DEFAULT_ADMIN.allowIps];
418
+
419
+ const logSize = Number(source.logSize);
420
+
421
+ return {
422
+ enabled:
423
+ env === undefined
424
+ ? source.enabled === true
425
+ : env !== "0" && env !== "false" && env !== "",
426
+ basePath: basePath || DEFAULT_ADMIN.basePath,
427
+ allowIps,
428
+ blockBots: source.blockBots !== false,
429
+ banAttempts: Math.floor(positive("banAttempts", 1)),
430
+ banHours: positive("banHours", 0),
431
+ sessionHours: positive("sessionHours", 0),
432
+ logSize:
433
+ Number.isFinite(logSize) && logSize >= 50
434
+ ? Math.min(5000, Math.floor(logSize))
435
+ : DEFAULT_ADMIN.logSize,
436
+ };
437
+ }
438
+
439
+ /**
440
+ * Cloudflare bölümü. Token burada da verilebiliyor ama önerilen yol env;
441
+ * normalizasyon sadece tipleri sabitler, sırrı okumak `cloudflare.js`'in işi.
442
+ *
443
+ * @param {unknown} raw
444
+ * @returns {typeof DEFAULT_CLOUDFLARE}
445
+ */
446
+ function normalizeCloudflare(raw) {
447
+ const source = /** @type {Record<string, any>} */ (raw ?? {});
448
+ const hours = Number(source.analyticsHours);
449
+
450
+ /** @param {unknown} value */
451
+ const text = (value) => (typeof value === "string" && value ? value : null);
452
+
453
+ return {
454
+ enabled: source.enabled !== false,
455
+ zoneId: text(source.zoneId),
456
+ apiToken: text(source.apiToken),
457
+ // Şema yazılırsa purge URL'i `https://https://…` olur; baştaki şema atılır.
458
+ hostname: text(source.hostname)?.replace(/^https?:\/\//, "") ?? null,
459
+ analyticsHours:
460
+ Number.isFinite(hours) && hours > 0
461
+ ? Math.min(72, Math.floor(hours))
462
+ : DEFAULT_CLOUDFLARE.analyticsHours,
463
+ };
464
+ }
465
+
466
+ const LOG_KINDS = new Set(["http", "event", "error"]);
467
+
468
+ /**
469
+ * `bucket`, `JSKELET_LOG_BUCKET` veya `JSKELET_S3_BUCKET` değeri
470
+ * `ayberkenis/jskelet/logs` gibi bir yol olabilir: ilk segment bucket adı,
471
+ * kalanı nesne öneki. Böylece tek env ile hem kova hem klasör verilmiş olur.
472
+ *
473
+ * @param {string | null} value
474
+ * @returns {{ bucket: string | null, prefix: string | null }}
475
+ * `prefix` null → yol öneki taşımıyor; config/varsayılan kalsın.
476
+ */
477
+ export function splitS3BucketPath(value) {
478
+ if (!value) return { bucket: null, prefix: null };
479
+
480
+ const trimmed = value.replace(/^\/+|\/+$/g, "");
481
+ if (!trimmed) return { bucket: null, prefix: null };
482
+
483
+ const slash = trimmed.indexOf("/");
484
+ if (slash < 0) return { bucket: trimmed, prefix: null };
485
+
486
+ const bucket = trimmed.slice(0, slash);
487
+ const rest = trimmed.slice(slash + 1).replace(/^\/+|\/+$/g, "");
488
+ if (!bucket) return { bucket: null, prefix: null };
489
+
490
+ return {
491
+ bucket,
492
+ prefix: rest ? `${rest}/` : null,
493
+ };
494
+ }
495
+
496
+ /**
497
+ * @param {unknown} value
498
+ * @returns {string | null}
499
+ */
500
+ function envText(value) {
501
+ return typeof value === "string" && value ? value : null;
502
+ }
503
+
504
+ /**
505
+ * @returns {{ accessKeyId: string, secretAccessKey: string,
506
+ * sessionToken: string | null } | null}
507
+ */
508
+ export function readS3CredentialsFromEnv() {
509
+ const accessKeyId = envText(process.env.JSKELET_S3_ACCESS_KEY_ID);
510
+ const secretAccessKey =
511
+ envText(process.env.JSKELET_S3_SECRET_ACCESS_KEY) ??
512
+ envText(process.env.JSKELET_S3_ACCESS_SECRET);
513
+ if (!accessKeyId || !secretAccessKey) return null;
514
+ return {
515
+ accessKeyId,
516
+ secretAccessKey,
517
+ sessionToken: envText(process.env.JSKELET_S3_SESSION_TOKEN),
518
+ };
519
+ }
520
+
521
+ /**
522
+ * Log hedefi. Öncelik: `JSKELET_LOG_BUCKET` → `JSKELET_S3_BUCKET`
523
+ * (+ isteğe bağlı `JSKELET_S3_KEY_PREFIX`).
524
+ *
525
+ * @returns {string | null}
526
+ */
527
+ function resolveLogBucketEnv() {
528
+ const logPath = envText(process.env.JSKELET_LOG_BUCKET);
529
+ if (logPath) return logPath;
530
+
531
+ const bucket = envText(process.env.JSKELET_S3_BUCKET);
532
+ if (!bucket) return null;
533
+
534
+ const prefix = envText(process.env.JSKELET_S3_KEY_PREFIX);
535
+ return prefix ? `${bucket}/${prefix}` : bucket;
536
+ }
537
+
538
+ /**
539
+ * Kalıcı log sink'leri. Bozuk bir `kinds` listesi siteyi düşürmemeli —
540
+ * bilinmeyen girdiler atılır; hiç geçerli tür kalmazsa varsayılana dönülür.
541
+ *
542
+ * @param {unknown} raw
543
+ * @returns {LogsConfig}
544
+ */
545
+ export function normalizeLogs(raw) {
546
+ const source = /** @type {Record<string, any>} */ (raw ?? {});
547
+ const fileRaw = /** @type {Record<string, any>} */ (source.file ?? {});
548
+ const s3Raw = /** @type {Record<string, any>} */ (source.s3 ?? {});
549
+
550
+ /** @type {LogKind[]} */
551
+ let kinds = DEFAULT_LOGS.kinds;
552
+ if (Array.isArray(source.kinds)) {
553
+ const filtered = source.kinds.filter(
554
+ (entry) => typeof entry === "string" && LOG_KINDS.has(entry),
555
+ );
556
+ if (filtered.length) kinds = /** @type {LogKind[]} */ ([...new Set(filtered)]);
557
+ else {
558
+ console.warn(
559
+ "[config] logs.kinds has no valid entries (http|event|error), using defaults",
560
+ );
561
+ }
562
+ } else if (source.kinds != null) {
563
+ console.warn("[config] logs.kinds must be an array, using defaults");
564
+ }
565
+
566
+ const flush = Number(s3Raw.flushIntervalMs);
567
+ const batch = Number(s3Raw.maxBatch);
568
+
569
+ const bucketPath = splitS3BucketPath(
570
+ resolveLogBucketEnv() ?? envText(s3Raw.bucket),
571
+ );
572
+
573
+ const configPrefix =
574
+ typeof s3Raw.prefix === "string" && s3Raw.prefix
575
+ ? s3Raw.prefix.endsWith("/")
576
+ ? s3Raw.prefix
577
+ : `${s3Raw.prefix}/`
578
+ : DEFAULT_LOGS.s3.prefix;
579
+
580
+ const endpoint =
581
+ envText(process.env.JSKELET_S3_API_URL) ?? envText(s3Raw.endpoint);
582
+
583
+ // Uyumlu API'lerde (Cloudflare R2 vb.) imza bölgesi çoğu zaman `auto`.
584
+ // Region hiçbir kurulumda zorunlu değil.
585
+ const region =
586
+ envText(s3Raw.region) ??
587
+ envText(process.env.JSKELET_S3_REGION) ??
588
+ "auto";
589
+
590
+ /** @type {import('../server/logs/file-sink.js').DrainLog | null} */
591
+ let drainLog = null;
592
+ if (typeof source.drainLog === "function") {
593
+ drainLog = source.drainLog;
594
+ } else if (source.drainLog != null) {
595
+ console.warn("[config] logs.drainLog must be a function, ignoring it");
596
+ }
597
+
598
+ const credentials = readS3CredentialsFromEnv();
599
+ // `JSKELET_LOG_BUCKET` (veya S3 bucket) + credential varsa config'te
600
+ // `enabled: true` unutulmuş olsa bile aç. Açık `enabled: false` ezer.
601
+ const envWantsLogs = Boolean(
602
+ envText(process.env.JSKELET_LOG_BUCKET) ||
603
+ envText(process.env.JSKELET_S3_BUCKET),
604
+ );
605
+ const enabled =
606
+ s3Raw.enabled === true ||
607
+ (s3Raw.enabled !== false &&
608
+ envWantsLogs &&
609
+ Boolean(credentials) &&
610
+ Boolean(bucketPath.bucket));
611
+
612
+ return {
613
+ console: source.console !== false,
614
+ kinds,
615
+ file: {
616
+ enabled: fileRaw.enabled === true,
617
+ dir:
618
+ typeof fileRaw.dir === "string" && fileRaw.dir.trim()
619
+ ? fileRaw.dir.trim()
620
+ : DEFAULT_LOGS.file.dir,
621
+ rotate: "daily",
622
+ },
623
+ drainLog,
624
+ s3: {
625
+ enabled,
626
+ bucket: bucketPath.bucket,
627
+ // Yoldaki önek tek env ile klasör vermeyi mümkün kılar; yoksa config.
628
+ prefix: bucketPath.prefix ?? configPrefix,
629
+ region,
630
+ endpoint,
631
+ flushIntervalMs:
632
+ Number.isFinite(flush) && flush >= 500
633
+ ? Math.min(60_000, Math.floor(flush))
634
+ : DEFAULT_LOGS.s3.flushIntervalMs,
635
+ maxBatch:
636
+ Number.isFinite(batch) && batch >= 1
637
+ ? Math.min(5000, Math.floor(batch))
638
+ : DEFAULT_LOGS.s3.maxBatch,
639
+ },
640
+ };
641
+ }
642
+
643
+ /**
644
+ * `cache().query` → yol deseni başına, cache anahtarına girmesine izin verilen
645
+ * query parametreleri.
646
+ *
647
+ * Varsayılan bilinçli olarak "query varsa sayfa dinamik": bir yolun bütün
648
+ * query varyantlarını cache'lemek, `?utm_source=…` gibi sonsuz sayıda anahtar
649
+ * üretip LRU'daki gerçek sayfaları dışarı atıyor. Hangi parametrenin çıktıyı
650
+ * gerçekten değiştirdiğini yalnızca uygulama bilir, o yüzden izin listesi
651
+ * config'ten gelir.
652
+ *
653
+ * Bir desen `true` ile eşlenirse bütün parametreler anahtara girer (eski
654
+ * davranış), `[]` ile eşlenirse hiçbiri girmez — yani query yok sayılır ve
655
+ * bütün varyantlar query'siz sürümün HTML'ini paylaşır.
656
+ *
657
+ * @param {unknown} raw
658
+ * @returns {ResolvedConfig["cacheQuery"]}
659
+ */
660
+ function normalizeQueryRules(raw) {
661
+ /** @type {ResolvedConfig["cacheQuery"]} */
662
+ const out = [];
663
+
664
+ for (const [source, value] of Object.entries(raw ?? {})) {
665
+ const pattern = compilePattern(source);
666
+ if (!pattern) continue;
667
+
668
+ if (value === true) {
669
+ out.push({ pattern, allow: true });
670
+ continue;
671
+ }
672
+ if (value === false) continue;
673
+
674
+ const allow = asArray(
675
+ typeof value === "string" ? [value] : value,
676
+ `cache().query["${source}"]`,
677
+ )
678
+ .filter((name) => typeof name === "string" && name)
679
+ .map(String);
680
+ out.push({ pattern, allow });
681
+ }
682
+
683
+ return out;
684
+ }
685
+
686
+ /**
687
+ * `cache().vary` → HTML anahtarına host / header / özel fn parçası.
688
+ *
689
+ * @param {unknown} raw
690
+ * @returns {ResolvedConfig["cacheVary"]}
691
+ */
692
+ function normalizeVary(raw) {
693
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
694
+ return { host: false, headers: [], fn: null };
695
+ }
696
+
697
+ const source = /** @type {Record<string, unknown>} */ (raw);
698
+ const headers = asArray(source.headers, "cache().vary.headers")
699
+ .filter((name) => typeof name === "string" && name)
700
+ .map((name) => String(name).toLowerCase());
701
+
702
+ /** @type {ResolvedConfig["cacheVary"]["fn"]} */
703
+ let fn = null;
704
+ if (typeof source.fn === "function") {
705
+ fn = /** @type {ResolvedConfig["cacheVary"]["fn"]} */ (source.fn);
706
+ } else if (source.fn != null) {
707
+ console.warn("[config] cache().vary.fn must be a function, ignoring it");
708
+ }
709
+
710
+ return {
711
+ host: source.host === true,
712
+ headers,
713
+ fn,
714
+ };
715
+ }
716
+
717
+ /**
718
+ * @param {unknown} raw
719
+ * @returns {{ html: ResolvedConfig["html"],
720
+ * cacheQuery: ResolvedConfig["cacheQuery"],
721
+ * cacheVary: ResolvedConfig["cacheVary"], htmlMaxEntries: number,
722
+ * staleWhileRevalidate: number,
723
+ * data: Record<string, unknown>, trackUpstream: boolean,
724
+ * trackDependencies: boolean,
725
+ * transientRetry: { attempts: number, delayMs: number },
726
+ * redis: RedisConfig,
727
+ * upstream: typeof DEFAULT_UPSTREAM_LIMIT,
728
+ * cloudflare: typeof DEFAULT_CLOUDFLARE,
729
+ * prewarm: Record<string, unknown>,
730
+ * prewarmPriority: ResolvedConfig["prewarmPriority"] }}
731
+ */
732
+ function normalizeCache(raw) {
733
+ /** @type {ResolvedConfig["html"]} */
734
+ const html = [];
735
+
736
+ for (const [source, seconds] of Object.entries(raw?.html ?? {})) {
737
+ const pattern = compilePattern(source);
738
+ const value = Number(seconds);
739
+ if (!pattern || !Number.isFinite(value) || value < 0) continue;
740
+ html.push({ pattern, seconds: value });
741
+ }
742
+
743
+ const prewarm = normalizePrewarm(raw?.prewarm);
744
+ const queryRules = normalizeQueryRules(raw?.query);
745
+ const maxEntries = Number(raw?.maxEntries);
746
+ let htmlMaxEntries =
747
+ Number.isFinite(maxEntries) && maxEntries > 0
748
+ ? Math.floor(maxEntries)
749
+ : DEFAULT_HTML_CACHE_MAX_ENTRIES;
750
+ if (htmlMaxEntries > HTML_CACHE_MAX_ENTRIES_CEILING) {
751
+ console.warn(
752
+ `[config] cache().maxEntries ${htmlMaxEntries} exceeds the ceiling of ` +
753
+ `${HTML_CACHE_MAX_ENTRIES_CEILING}; using ${HTML_CACHE_MAX_ENTRIES_CEILING}`,
754
+ );
755
+ htmlMaxEntries = HTML_CACHE_MAX_ENTRIES_CEILING;
756
+ }
757
+
758
+ return {
759
+ html,
760
+ cacheQuery: queryRules,
761
+ cacheVary: normalizeVary(raw?.vary),
762
+ htmlMaxEntries,
763
+ staleWhileRevalidate: normalizeStaleWhileRevalidate(raw?.staleWhileRevalidate),
764
+ data: normalizeDataCache(raw?.data),
765
+ // Otomatik upstream izleme kapatılabilir olmalı: `fetch`i kendisi saran
766
+ // bir uygulama (ölçüm, retry, circuit breaker) çakışma yaşayabilir.
767
+ trackUpstream: raw?.trackUpstream !== false,
768
+ // Hangi sayfanın hangi veri anahtarını okuduğu kaydedilsin mi.
769
+ // `withDataCache` kullanmayan bir uygulamada kaydedilecek bir şey yok;
770
+ // kapatmak bağlam kurma maliyetini de kaldırır.
771
+ trackDependencies: raw?.trackDependencies !== false,
772
+ transientRetry:
773
+ raw?.transientRetry === false
774
+ ? { attempts: 0, delayMs: 0 }
775
+ : { ...DEFAULT_TRANSIENT_RETRY, ...(raw?.transientRetry ?? {}) },
776
+ redis: normalizeRedis(raw?.redis),
777
+ upstream: normalizeUpstream(raw?.upstream),
778
+ cloudflare: normalizeCloudflare(raw?.cloudflare),
779
+ // Desenler derlenmiş hâlde ayrı alanda tutulur: `prewarm` sayısal
780
+ // ayarların düz torbası olarak kalsın, her turda yeniden derlenmesin.
781
+ prewarm,
782
+ prewarmPriority: normalizePriority(prewarm.priority),
783
+ };
784
+ }
785
+
786
+ /**
787
+ * Edge stale penceresi. Boş değer varsayılan 60'tır; `0` direktifi kapatır.
788
+ * Negatif veya sonlu olmayan değer uyarıyla varsayılana döner.
789
+ *
790
+ * @param {unknown} raw
791
+ * @returns {number}
792
+ */
793
+ function normalizeStaleWhileRevalidate(raw) {
794
+ if (raw == null || raw === "") return DEFAULT_STALE_WHILE_REVALIDATE;
795
+
796
+ const value = Number(raw);
797
+ if (!Number.isFinite(value) || value < 0) {
798
+ console.warn(
799
+ `[config] cache().staleWhileRevalidate ${raw} is not a non-negative number; ` +
800
+ `using ${DEFAULT_STALE_WHILE_REVALIDATE}`,
801
+ );
802
+ return DEFAULT_STALE_WHILE_REVALIDATE;
803
+ }
804
+
805
+ return value;
806
+ }
807
+
808
+ /**
809
+ * İstenen pozitif tamsayı tavanı aşıyorsa uyarı basıp tavana çeker.
810
+ *
811
+ * @param {string} field
812
+ * @param {number} requested
813
+ * @param {number} ceiling
814
+ * @returns {number}
815
+ */
816
+ function clampCeiling(field, requested, ceiling) {
817
+ if (requested <= ceiling) return requested;
818
+ console.warn(
819
+ `[config] ${field} ${requested} exceeds the ceiling of ${ceiling}; using ${ceiling}`,
820
+ );
821
+ return ceiling;
822
+ }
823
+
824
+ /**
825
+ * Veri önbelleği JSON tutar; sınır HTML'den yüksek olabilir ama sonsuz değil.
826
+ *
827
+ * @param {unknown} raw
828
+ * @returns {{ maxEntries: number, staleFactor: number }}
829
+ */
830
+ function normalizeDataCache(raw) {
831
+ const source =
832
+ raw && typeof raw === "object" && !Array.isArray(raw)
833
+ ? /** @type {Record<string, unknown>} */ (raw)
834
+ : {};
835
+ const requested = Number(source.maxEntries);
836
+ let maxEntries =
837
+ Number.isFinite(requested) && requested > 0
838
+ ? Math.floor(requested)
839
+ : DEFAULT_DATA_CACHE.maxEntries;
840
+ maxEntries = clampCeiling(
841
+ "cache().data.maxEntries",
842
+ maxEntries,
843
+ DATA_CACHE_MAX_ENTRIES_CEILING,
844
+ );
845
+
846
+ const stale = Number(source.staleFactor);
847
+ return {
848
+ maxEntries,
849
+ staleFactor:
850
+ Number.isFinite(stale) && stale >= 0 ? stale : DEFAULT_DATA_CACHE.staleFactor,
851
+ };
852
+ }
853
+
854
+ /**
855
+ * onVisit sürekli çalışır. Klasik turdaki `rps: 0` (sınırsız) burada her
856
+ * ziyaretçide yeniden crawl demek; boş, `0` ve tavanın üstü 2'ye çekilir.
857
+ *
858
+ * @param {Record<string, unknown>} source
859
+ * @returns {{ enabled: boolean } & typeof DEFAULT_PREWARM_ON_VISIT}
860
+ */
861
+ function resolveOnVisitLimits(source) {
862
+ const perPageRaw = Number(source.perPage);
863
+ const perPage = clampCeiling(
864
+ "cache().prewarm.onVisit.perPage",
865
+ Number.isFinite(perPageRaw) && perPageRaw > 0
866
+ ? Math.floor(perPageRaw)
867
+ : DEFAULT_PREWARM_ON_VISIT.perPage,
868
+ ON_VISIT_PER_PAGE_CEILING,
869
+ );
870
+
871
+ const concurrencyRaw = Number(source.concurrency);
872
+ const concurrency = clampCeiling(
873
+ "cache().prewarm.onVisit.concurrency",
874
+ Number.isFinite(concurrencyRaw) && concurrencyRaw > 0
875
+ ? Math.floor(concurrencyRaw)
876
+ : ON_VISIT_CONCURRENCY_CEILING,
877
+ ON_VISIT_CONCURRENCY_CEILING,
878
+ );
879
+
880
+ const rpsRaw = Number(source.rps);
881
+ let rps = ON_VISIT_RPS_CEILING;
882
+ if (source.rps != null && source.rps !== "") {
883
+ if (!Number.isFinite(rpsRaw) || rpsRaw <= 0) {
884
+ console.warn(
885
+ `[config] cache().prewarm.onVisit.rps ${source.rps} is not a positive rate; ` +
886
+ `using ${ON_VISIT_RPS_CEILING}`,
887
+ );
888
+ } else {
889
+ rps = clampCeiling(
890
+ "cache().prewarm.onVisit.rps",
891
+ rpsRaw,
892
+ ON_VISIT_RPS_CEILING,
893
+ );
894
+ }
895
+ }
896
+
897
+ return {
898
+ ...DEFAULT_PREWARM_ON_VISIT,
899
+ enabled: source.enabled !== false,
900
+ perPage,
901
+ concurrency,
902
+ rps,
903
+ };
904
+ }
905
+
906
+ /**
907
+ * @param {unknown} raw
908
+ * @returns {{ enabled: boolean } & typeof DEFAULT_PREWARM_ON_VISIT}
909
+ */
910
+ function normalizeOnVisit(raw) {
911
+ if (raw === true) return resolveOnVisitLimits({});
912
+
913
+ if (raw == null || raw === false) {
914
+ return { ...DEFAULT_PREWARM_ON_VISIT, enabled: false };
915
+ }
916
+
917
+ if (typeof raw !== "object" || Array.isArray(raw)) {
918
+ throw new Error(
919
+ "[config] cache().prewarm.onVisit must be true, false, or an object",
920
+ );
921
+ }
922
+
923
+ return resolveOnVisitLimits(/** @type {Record<string, unknown>} */ (raw));
924
+ }
925
+
926
+ /**
927
+ * Klasik liste ısıtması ile `onVisit` karşılıklı dışlayıcıdır. İkisini birden
928
+ * yazmak sessizce yanlış moda düşmesin diye yüklemede hata verir.
929
+ *
930
+ * @param {unknown} raw
931
+ * @returns {Record<string, unknown>}
932
+ */
933
+ function normalizePrewarm(raw) {
934
+ const source =
935
+ raw && typeof raw === "object" && !Array.isArray(raw)
936
+ ? /** @type {Record<string, unknown>} */ ({ ...raw })
937
+ : {};
938
+
939
+ const onVisit = normalizeOnVisit(source.onVisit);
940
+
941
+ if (onVisit.enabled) {
942
+ const conflicts = Object.keys(source).filter(
943
+ (key) => key !== "onVisit" && CLASSIC_PREWARM_KEYS.includes(key),
944
+ );
945
+ if (conflicts.length) {
946
+ throw new Error(
947
+ "[config] cache().prewarm.onVisit cannot be combined with classic " +
948
+ `prewarm settings (${conflicts.join(", ")}). Use either onVisit or ` +
949
+ "classic settings (max, priority, rotate, …), not both.",
950
+ );
951
+ }
952
+
953
+ const unknown = Object.keys(source).filter((key) => key !== "onVisit");
954
+ if (unknown.length) {
955
+ throw new Error(
956
+ "[config] cache().prewarm.onVisit cannot be combined with " +
957
+ `${unknown.join(", ")}. On-visit mode only accepts the onVisit object.`,
958
+ );
959
+ }
960
+
961
+ return {
962
+ ...DEFAULT_PREWARM,
963
+ enabled: true,
964
+ onVisit,
965
+ };
966
+ }
967
+
968
+ // Klasik mod: `onVisit: false` yazılmış olabilir; diğer alanlar varsayılanlarla
969
+ // birleşir. `onVisit` anahtarı çözülmüş nesnede her zaman durur.
970
+ const classic = { ...source };
971
+ delete classic.onVisit;
972
+
973
+ const origins = asArray(classic.origins, "cache().prewarm.origins")
974
+ .filter((value) => typeof value === "string" && /^https?:\/\//i.test(value))
975
+ .map(String);
976
+ classic.origins = origins;
977
+
978
+ return {
979
+ ...DEFAULT_PREWARM,
980
+ ...classic,
981
+ onVisit,
982
+ };
983
+ }
984
+
985
+ /** Speculation Rules'un tanıdığı eagerness değerleri. */
986
+ const EAGERNESS = new Set(["conservative", "moderate", "eager"]);
987
+
988
+ /**
989
+ * `true` → varsayılan eagerness, `false` → kapalı, string → doğrulanır.
990
+ * Geçersiz bir değer siteyi düşürmemeli; uyarı basılıp varsayılana dönülür.
991
+ *
992
+ * @param {unknown} value
993
+ * @param {false | Eagerness} fallback
994
+ * @param {string} label
995
+ * @returns {false | Eagerness}
996
+ */
997
+ function normalizeEagerness(value, fallback, label) {
998
+ if (value === undefined) return fallback;
999
+ if (value === false) return false;
1000
+ if (value === true) return fallback === false ? "moderate" : fallback;
1001
+ if (typeof value === "string" && EAGERNESS.has(value)) {
1002
+ return /** @type {Eagerness} */ (value);
1003
+ }
1004
+
1005
+ console.warn(
1006
+ `[config] navigation.${label} is invalid (${String(value)}), falling back to the default`,
1007
+ );
1008
+ return fallback;
1009
+ }
1010
+
1011
+ /**
1012
+ * @param {unknown} raw
1013
+ * @param {Record<string, unknown>} brand
1014
+ * @returns {NavigationConfig}
1015
+ */
1016
+ function normalizeNavigation(raw, brand) {
1017
+ const source = /** @type {Record<string, unknown>} */ (raw ?? {});
1018
+
1019
+ // Dev araçlarının yolu spekülasyona kapalı: overlay ve rapor uçları gerçek
1020
+ // sayfa değil, önden getirilmelerinin hiçbir karşılığı yok.
1021
+ const devBase = typeof brand.devBasePath === "string" ? brand.devBasePath : null;
1022
+
1023
+ return {
1024
+ prefetch: normalizeEagerness(
1025
+ source.prefetch,
1026
+ DEFAULT_NAVIGATION.prefetch,
1027
+ "prefetch",
1028
+ ),
1029
+ prerender: normalizeEagerness(
1030
+ source.prerender,
1031
+ DEFAULT_NAVIGATION.prerender,
1032
+ "prerender",
1033
+ ),
1034
+ viewTransition: source.viewTransition === true,
1035
+ exclude: [
1036
+ ...DEFAULT_NAVIGATION_EXCLUDE,
1037
+ ...(devBase ? [`${devBase}/*`] : []),
1038
+ ...asArray(source.exclude, "navigation.exclude").filter(
1039
+ (entry) => typeof entry === "string",
1040
+ ),
1041
+ ].map(String),
1042
+ };
1043
+ }
1044
+
1045
+ /**
1046
+ * @typedef {object} SecurityConfig
1047
+ * @property {boolean} trustProxy
1048
+ * @property {string | null} cookieSecret
1049
+ * @property {{ enabled: boolean, token: boolean, allowedOrigins: string[],
1050
+ * exclude: CompiledPattern[], cookieName: string, fieldName: string,
1051
+ * headerName: string }} csrf
1052
+ */
1053
+
1054
+ /**
1055
+ * @param {unknown} raw
1056
+ * @returns {Record<string, unknown>}
1057
+ */
1058
+ function normalizeBrand(raw) {
1059
+ const source = /** @type {Record<string, unknown>} */ (raw ?? {});
1060
+ const roots = asArray(
1061
+ source.sharedCookieRoots ?? DEFAULT_BRAND.sharedCookieRoots,
1062
+ "brand.sharedCookieRoots",
1063
+ )
1064
+ .filter((entry) => typeof entry === "string")
1065
+ .map((entry) => {
1066
+ const trimmed = String(entry).trim().toLowerCase();
1067
+ if (!trimmed) return null;
1068
+ return trimmed.startsWith(".") ? trimmed : `.${trimmed}`;
1069
+ })
1070
+ .filter((entry) => entry !== null);
1071
+
1072
+ return {
1073
+ ...DEFAULT_BRAND,
1074
+ ...source,
1075
+ sharedCookieRoots: roots,
1076
+ };
1077
+ }
1078
+
1079
+ /**
1080
+ * @param {unknown} raw
1081
+ * @returns {{ crossSubdomainHandoff: boolean | Record<string, unknown> }}
1082
+ */
1083
+ function normalizeAuth(raw) {
1084
+ if (raw == null || typeof raw !== "object" || Array.isArray(raw)) {
1085
+ return { ...DEFAULT_AUTH };
1086
+ }
1087
+
1088
+ const source = /** @type {Record<string, unknown>} */ (raw);
1089
+ const handoff = source.crossSubdomainHandoff;
1090
+
1091
+ if (handoff === true || handoff === false || handoff == null) {
1092
+ return {
1093
+ crossSubdomainHandoff: handoff === true,
1094
+ };
1095
+ }
1096
+
1097
+ if (typeof handoff === "object" && !Array.isArray(handoff)) {
1098
+ return { crossSubdomainHandoff: { ...handoff } };
1099
+ }
1100
+
1101
+ console.warn(
1102
+ "[config] auth.crossSubdomainHandoff must be boolean or object, ignoring it",
1103
+ );
1104
+ return { ...DEFAULT_AUTH };
1105
+ }
1106
+
1107
+ /**
1108
+ * Güvenlik bölümü. `csrf.exclude` desenleri burada derlenir: her istekte
1109
+ * yeniden derlemek gereksiz, ve bozuk bir desen sunucuyu düşürmemeli.
1110
+ *
1111
+ * @param {unknown} raw
1112
+ * @returns {SecurityConfig}
1113
+ */
1114
+ function normalizeSecurity(raw) {
1115
+ const source = /** @type {Record<string, any>} */ (raw ?? {});
1116
+ const csrf = { ...DEFAULT_SECURITY.csrf, ...(source.csrf ?? {}) };
1117
+
1118
+ const exclude = asArray(csrf.exclude, "security.csrf.exclude")
1119
+ .map((entry) => compilePattern(entry))
1120
+ .filter((pattern) => pattern !== null);
1121
+
1122
+ return {
1123
+ trustProxy: source.trustProxy !== false,
1124
+ cookieSecret:
1125
+ typeof source.cookieSecret === "string" && source.cookieSecret
1126
+ ? source.cookieSecret
1127
+ : null,
1128
+ csrf: {
1129
+ enabled: csrf.enabled !== false,
1130
+ token: csrf.token === true,
1131
+ allowedOrigins: asArray(csrf.allowedOrigins, "security.csrf.allowedOrigins")
1132
+ .filter((entry) => typeof entry === "string")
1133
+ .map(String),
1134
+ exclude: /** @type {CompiledPattern[]} */ (exclude),
1135
+ cookieName: String(csrf.cookieName ?? DEFAULT_SECURITY.csrf.cookieName),
1136
+ fieldName: String(csrf.fieldName ?? DEFAULT_SECURITY.csrf.fieldName),
1137
+ headerName: String(csrf.headerName ?? DEFAULT_SECURITY.csrf.headerName).toLowerCase(),
1138
+ },
1139
+ };
1140
+ }
1141
+
1142
+ /**
1143
+ * İkon sprite ayarları. `false` → adım atlanır. `dir` varsayılanı `"icons"`:
1144
+ * o dizin varsa yalnızca yerel SVG'ler; yoksa Phosphor.
1145
+ *
1146
+ * @param {unknown} raw
1147
+ * @returns {{ scan?: string[], dir: string } | false}
1148
+ */
1149
+ function normalizeIcons(raw) {
1150
+ if (raw === false) return false;
1151
+
1152
+ const source = /** @type {Record<string, any>} */ (raw ?? {});
1153
+ const dir =
1154
+ typeof source.dir === "string" && source.dir.trim()
1155
+ ? source.dir.trim()
1156
+ : "icons";
1157
+
1158
+ /** @type {{ scan?: string[], dir: string }} */
1159
+ const icons = { dir };
1160
+
1161
+ if (source.scan != null) {
1162
+ icons.scan = asArray(source.scan, "icons.scan")
1163
+ .filter((entry) => typeof entry === "string" && entry.trim())
1164
+ .map((entry) => String(entry).trim());
1165
+ }
1166
+
1167
+ return icons;
1168
+ }
1169
+
1170
+ /**
1171
+ * Build + runtime görsel ayarları. `false` → her iki yüzey de kapalı.
1172
+ * `remote.allowHosts` boşsa remote kapalı kalır (açık proxy olmasın).
1173
+ *
1174
+ * @param {unknown} raw
1175
+ * @returns {ImagesConfig | false}
1176
+ */
1177
+ function normalizeImages(raw) {
1178
+ if (raw === false) return false;
1179
+
1180
+ const source = /** @type {Record<string, any>} */ (raw ?? {});
1181
+ const widths = asArray(source.widths ?? DEFAULT_IMAGES.widths, "images.widths")
1182
+ .map((entry) => Number(entry))
1183
+ .filter((entry) => Number.isFinite(entry) && entry > 0)
1184
+ .map((entry) => Math.round(entry));
1185
+
1186
+ const quality = Number(source.quality ?? DEFAULT_IMAGES.quality);
1187
+ const skip = asArray(source.skip ?? DEFAULT_IMAGES.skip, "images.skip")
1188
+ .filter((entry) => typeof entry === "string")
1189
+ .map(String);
1190
+
1191
+ /** @type {ImagesRemoteConfig | false} */
1192
+ let remote = false;
1193
+ if (source.remote !== false && source.remote != null) {
1194
+ const rem = /** @type {Record<string, any>} */ (
1195
+ source.remote === true ? {} : source.remote
1196
+ );
1197
+ const allowHosts = asArray(
1198
+ rem.allowHosts ?? DEFAULT_IMAGES.remote.allowHosts,
1199
+ "images.remote.allowHosts",
1200
+ )
1201
+ .filter((entry) => typeof entry === "string" && entry.trim())
1202
+ .map((entry) => String(entry).trim().toLowerCase());
1203
+
1204
+ if (allowHosts.length === 0) {
1205
+ if (source.remote === true || rem.allowHosts != null) {
1206
+ console.warn(
1207
+ "[config] images.remote needs a non-empty allowHosts list; remote optimizer disabled",
1208
+ );
1209
+ }
1210
+ } else {
1211
+ remote = {
1212
+ enabled: true,
1213
+ allowHosts,
1214
+ path: String(rem.path ?? DEFAULT_IMAGES.remote.path),
1215
+ maxWidth: Math.max(
1216
+ 1,
1217
+ Number(rem.maxWidth ?? DEFAULT_IMAGES.remote.maxWidth) ||
1218
+ DEFAULT_IMAGES.remote.maxWidth,
1219
+ ),
1220
+ cacheMaxAge: Math.max(
1221
+ 0,
1222
+ Number(rem.cacheMaxAge ?? DEFAULT_IMAGES.remote.cacheMaxAge) ||
1223
+ DEFAULT_IMAGES.remote.cacheMaxAge,
1224
+ ),
1225
+ fetchTimeoutMs: Math.max(
1226
+ 1000,
1227
+ Number(rem.fetchTimeoutMs ?? DEFAULT_IMAGES.remote.fetchTimeoutMs) ||
1228
+ DEFAULT_IMAGES.remote.fetchTimeoutMs,
1229
+ ),
1230
+ maxBytes: Math.max(
1231
+ 1024,
1232
+ Number(rem.maxBytes ?? DEFAULT_IMAGES.remote.maxBytes) ||
1233
+ DEFAULT_IMAGES.remote.maxBytes,
1234
+ ),
1235
+ };
1236
+ }
1237
+ }
1238
+
1239
+ return {
1240
+ widths: widths.length ? widths : [...DEFAULT_IMAGES.widths],
1241
+ quality: Number.isFinite(quality) && quality > 0 ? quality : DEFAULT_IMAGES.quality,
1242
+ skip,
1243
+ remote,
1244
+ };
1245
+ }
1246
+
1247
+ /**
1248
+ * Dizin adlarını mutlak yola çevirir. `styles` bir dosya yolu olduğu için
1249
+ * de aynı çözümlemeden geçer; ayrı bir alan tutmaya değmez.
1250
+ *
1251
+ * @param {string} root
1252
+ * @param {Record<string, string>} [overrides]
1253
+ * @returns {Record<string, string>}
1254
+ */
1255
+ function resolveDirs(root, overrides) {
1256
+ /** @type {Record<string, string>} */
1257
+ const dirs = {};
1258
+ const merged = { ...DEFAULT_DIRS, ...(overrides ?? {}) };
1259
+
1260
+ for (const [key, value] of Object.entries(merged)) {
1261
+ dirs[key] = path.resolve(root, value);
1262
+ }
1263
+
1264
+ // Build çıktısı `public/assets` altına yazılır; ayrı ayar gerektirmeyecek
1265
+ // kadar sabit ama yol hesabı tek yerde kalsın.
1266
+ dirs.assets = path.join(dirs.public, "assets");
1267
+ dirs.fonts = path.join(dirs.public, "fonts");
1268
+
1269
+ return dirs;
1270
+ }
1271
+
1272
+ /**
1273
+ * Uygulamanın layout'u yoksa framework'ün minimal layout'u kullanılır. Bu
1274
+ * sayede yeni bir proje tek bir route ile çalışır hâle gelir.
1275
+ *
1276
+ * Öncelik: config `layout` → `layout.jsk` (derlenmiş) → `layout.ejs` →
1277
+ * framework varsayılanı. Dönüş değeri kaynak dosya yoludur; `.jsk` için
1278
+ * render katmanı derlenmiş modülü kullanır.
1279
+ *
1280
+ * @param {Record<string, string>} dirs
1281
+ * @param {string} [override]
1282
+ * @returns {string}
1283
+ */
1284
+ function resolveLayout(dirs, override) {
1285
+ if (override) return path.resolve(dirs.views, "..", override);
1286
+
1287
+ const jskLayout = path.join(dirs.views, "layout.jsk");
1288
+ if (fs.existsSync(jskLayout)) return jskLayout;
1289
+
1290
+ const appLayout = path.join(dirs.views, "layout.ejs");
1291
+ if (fs.existsSync(appLayout)) return appLayout;
1292
+
1293
+ return path.join(FRAMEWORK_ROOT, "src", "templates", "layout.jsk");
1294
+ }
1295
+
1296
+ /**
1297
+ * Config'i okur, normalize eder ve modül durumuna yazar. Sunucu ve build
1298
+ * süreçleri açılışta bir kez çağırır.
1299
+ *
1300
+ * Aynı süreçte ikinci çağrı önbelleğe düşer: `jskelet start` hem
1301
+ * `ensure-build` hem `createApp` üzerinden çağırıyor ve config'i iki kez
1302
+ * okuyup iki kez loglamanın hiçbir faydası yok. Yeniden okumak gerekiyorsa
1303
+ * `force: true`.
1304
+ *
1305
+ * @param {{ root?: string, configFile?: string, force?: boolean }} [options]
1306
+ * @returns {Promise<ResolvedConfig>}
1307
+ */
1308
+ export async function loadConfig(options = {}) {
1309
+ if (config && !options.force) return config;
1310
+
1311
+ const root = path.resolve(options.root ?? process.cwd());
1312
+ const configFile = options.configFile ?? CONFIG_FILE;
1313
+ const configPath = path.join(root, configFile);
1314
+
1315
+ /** @type {Record<string, any>} */
1316
+ let source = {};
1317
+ let loaded = false;
1318
+
1319
+ if (!fs.existsSync(configPath)) {
1320
+ console.warn(
1321
+ `[config] ${configFile} not found — continuing with built-in defaults.`,
1322
+ );
1323
+ } else {
1324
+ try {
1325
+ // Windows'ta mutlak yol import'u için file:// şeması gerekir.
1326
+ const module = await import(pathToFileURL(configPath).href);
1327
+ source = module.default ?? module;
1328
+ loaded = true;
1329
+ } catch (error) {
1330
+ console.warn(`[config] ${configFile} failed to load, ignoring it`, error);
1331
+ }
1332
+ }
1333
+
1334
+ /** @param {string} name */
1335
+ const section = async (name) => {
1336
+ const value = source?.[name];
1337
+ if (value == null) return null;
1338
+ try {
1339
+ return typeof value === "function" ? await value.call(source) : value;
1340
+ } catch (error) {
1341
+ console.warn(`[config] ${name}() threw, ignoring it`, error);
1342
+ return null;
1343
+ }
1344
+ };
1345
+
1346
+ const [headers, redirects, rewrites, cache, admin, logs] = await Promise.all([
1347
+ section("headers"),
1348
+ section("redirects"),
1349
+ section("rewrites"),
1350
+ section("cache"),
1351
+ section("admin"),
1352
+ section("logs"),
1353
+ ]);
1354
+
1355
+ const {
1356
+ html,
1357
+ cacheQuery,
1358
+ cacheVary,
1359
+ htmlMaxEntries,
1360
+ staleWhileRevalidate,
1361
+ data,
1362
+ trackUpstream,
1363
+ trackDependencies,
1364
+ transientRetry,
1365
+ redis,
1366
+ upstream,
1367
+ cloudflare,
1368
+ prewarm,
1369
+ prewarmPriority,
1370
+ } = normalizeCache(cache);
1371
+ const dirs = resolveDirs(root, source.paths);
1372
+ const brand = normalizeBrand(source.brand);
1373
+ const auth = normalizeAuth(source.auth);
1374
+
1375
+ config = {
1376
+ root,
1377
+ loaded,
1378
+ dirs,
1379
+ headers: normalizeHeaders(headers),
1380
+ redirects: normalizeRedirects(redirects),
1381
+ rewrites: normalizeRewrites(rewrites),
1382
+ html,
1383
+ cacheQuery,
1384
+ cacheVary,
1385
+ htmlMaxEntries,
1386
+ staleWhileRevalidate,
1387
+ data,
1388
+ trackUpstream,
1389
+ trackDependencies,
1390
+ transientRetry,
1391
+ redis,
1392
+ upstream,
1393
+ logs: normalizeLogs(logs),
1394
+ admin: normalizeAdmin(admin),
1395
+ cloudflare,
1396
+ prewarm,
1397
+ prewarmPriority,
1398
+ brand,
1399
+ auth,
1400
+ hooks: source.hooks ?? {},
1401
+ layout: resolveLayout(dirs, source.layout),
1402
+ routes: Array.isArray(source.routes) ? source.routes : null,
1403
+ // Varsayılan kapalı: açıkken `/hakkinda` → 308 `/hakkinda/` ve kanonik
1404
+ // yanıt 200'dir. Kapalıyken slash dayatılmaz — Express'in non-strict
1405
+ // eşleşmesi her iki biçimi de 200 ile servis eder (Next'in varsayılan
1406
+ // "slash'ı kırp" davranışından bilinçli fark).
1407
+ trailingSlash: source.trailingSlash === true,
1408
+ static: {
1409
+ extensions: new Set(source.static?.extensions ?? DEFAULT_STATIC.extensions),
1410
+ prefixes: source.static?.prefixes ?? DEFAULT_STATIC.prefixes,
1411
+ },
1412
+ devGate: normalizeDevGate(source),
1413
+ devGateBypass: source.devGateBypass ?? DEFAULT_DEV_GATE_BYPASS,
1414
+ preconnect: source.preconnect ?? [],
1415
+ navigation: normalizeNavigation(source.navigation, brand),
1416
+ security: normalizeSecurity(source.security),
1417
+ prewarmSkip: source.prewarmSkip ?? DEFAULT_PREWARM_SKIP,
1418
+ // `routes`, `views` ve `lib` zaten izlenir; buraya yalnızca ek dizinler.
1419
+ watch: source.watch ?? [],
1420
+ // Build tarafı ayarları. Sunucu bunları okumaz ama config tek dosya
1421
+ // olsun diye aynı yerden geçer.
1422
+ fonts: source.fonts ?? [],
1423
+ icons: normalizeIcons(source.icons),
1424
+ images: normalizeImages(source.images),
1425
+ clientEnv: source.clientEnv ?? [],
1426
+ };
1427
+
1428
+ if (
1429
+ config.prewarm?.onVisit?.enabled &&
1430
+ typeof config.hooks?.prewarmPaths === "function"
1431
+ ) {
1432
+ throw new Error(
1433
+ "[config] hooks.prewarmPaths() cannot be used with cache().prewarm.onVisit. " +
1434
+ "On-visit mode warms links from each response; classic mode uses prewarmPaths. " +
1435
+ "Choose one.",
1436
+ );
1437
+ }
1438
+
1439
+ // Dev'de build ve sunucu ayrı alt süreçler; üçü de aynı özeti basınca satır
1440
+ // banner'ın ve build bloğunun arasına üç kez giriyor. Özeti dış süreç basar.
1441
+ if (loaded && !process.env.JSKELET_CHILD) {
1442
+ /** @param {number} count @param {string} singular @param {string} plural */
1443
+ const label = (count, singular, plural) =>
1444
+ `${count} ${count === 1 ? singular : plural}`;
1445
+
1446
+ const counts = [
1447
+ config.headers.length && label(config.headers.length, "header", "headers"),
1448
+ config.redirects.length &&
1449
+ label(config.redirects.length, "redirect", "redirects"),
1450
+ config.rewrites.length && label(config.rewrites.length, "rewrite", "rewrites"),
1451
+ config.html.length && label(config.html.length, "cache rule", "cache rules"),
1452
+ ].filter(Boolean);
1453
+
1454
+ if (counts.length) {
1455
+ console.log(`[config] ${configFile} loaded — ${counts.join(", ")}`);
1456
+ }
1457
+ }
1458
+
1459
+ return config;
1460
+ }
1461
+
1462
+ /**
1463
+ * Çözümlenmiş config. `loadConfig()` çağrılmadan erişilirse boş bir proje
1464
+ * kökü varsayımıyla çalışmak yerine hata verir: sessiz yanlış yol,
1465
+ * "stylesheet neden yok" gibi teşhisi zor sorunlara dönüşüyor.
1466
+ *
1467
+ * @returns {ResolvedConfig}
1468
+ */
1469
+ export function getConfig() {
1470
+ if (!config) {
1471
+ throw new Error(
1472
+ "[config] getConfig() was used before loadConfig(). " +
1473
+ "Start the server with the `jskelet` CLI or through createApp().",
1474
+ );
1475
+ }
1476
+ return config;
1477
+ }
1478
+
1479
+ /**
1480
+ * Uygulamanın tanımladığı hook'u çalıştırır; yoksa `fallback` döner.
1481
+ * Hook'un hata vermesi sayfayı düşürmemeli — framework kendi varsayılanına
1482
+ * geri döner ve uyarır.
1483
+ *
1484
+ * @template T
1485
+ * @param {string} name
1486
+ * @param {T} fallback
1487
+ * @param {unknown[]} args
1488
+ * @returns {Promise<T>}
1489
+ */
1490
+ export async function hook(name, fallback, ...args) {
1491
+ const fn = getConfig().hooks?.[name];
1492
+ if (typeof fn !== "function") return fallback;
1493
+
1494
+ try {
1495
+ return await fn(...args);
1496
+ } catch (error) {
1497
+ console.warn(`[config] hooks.${name}() threw, using the default`, error);
1498
+ return fallback;
1499
+ }
1500
+ }