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