jskelet 0.1.1 → 0.1.3

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 (66) hide show
  1. package/AGENTS.md +5 -0
  2. package/CHANGELOG.md +129 -2
  3. package/README.md +21 -7
  4. package/bin/jskelet.mjs +6 -6
  5. package/docs/03-routing.md +48 -9
  6. package/docs/04-render-ve-sablonlar.md +2 -2
  7. package/docs/05-islands.md +59 -6
  8. package/docs/06-cache.md +240 -26
  9. package/docs/07-yapilandirma.md +108 -7
  10. package/docs/08-build.md +4 -4
  11. package/docs/09-dev-araclari.md +5 -0
  12. package/docs/12-panel-ve-oturum.md +384 -0
  13. package/docs/README.md +25 -2
  14. package/docs/en/01-getting-started.md +292 -0
  15. package/docs/en/02-architecture.md +305 -0
  16. package/docs/en/03-routing.md +493 -0
  17. package/docs/en/04-rendering.md +504 -0
  18. package/docs/en/05-islands.md +492 -0
  19. package/docs/en/06-caching.md +640 -0
  20. package/docs/en/07-configuration.md +789 -0
  21. package/docs/en/08-build.md +383 -0
  22. package/docs/en/09-dev-tools.md +314 -0
  23. package/docs/en/10-deployment.md +332 -0
  24. package/docs/en/11-migration.md +360 -0
  25. package/docs/en/12-dashboards-and-sessions.md +392 -0
  26. package/docs/en/README.md +112 -0
  27. package/package.json +4 -2
  28. package/src/build/build.mjs +1 -1
  29. package/src/build/tasks/client.mjs +2 -2
  30. package/src/build/tasks/fonts.mjs +3 -3
  31. package/src/build/tasks/icons.mjs +1 -1
  32. package/src/build/tasks/images.mjs +2 -2
  33. package/src/client/devtools/overlay.js +196 -164
  34. package/src/client/devtools/report.js +96 -96
  35. package/src/client/form.js +192 -0
  36. package/src/client/index.js +10 -1
  37. package/src/client/registry.js +78 -4
  38. package/src/client/swap.js +188 -0
  39. package/src/config/defaults.js +83 -0
  40. package/src/config/index.js +129 -18
  41. package/src/config/pattern.js +1 -1
  42. package/src/dev-server.mjs +1 -1
  43. package/src/http/control-flow.js +16 -1
  44. package/src/http/cookies.js +257 -0
  45. package/src/http/request-context.js +162 -0
  46. package/src/index.js +26 -2
  47. package/src/init.mjs +32 -31
  48. package/src/log.mjs +8 -2
  49. package/src/logo.png +0 -0
  50. package/src/runtime/alias-hooks.mjs +1 -1
  51. package/src/server/assets.js +1 -1
  52. package/src/server/create-app.js +12 -4
  53. package/src/server/data-cache.js +244 -0
  54. package/src/server/dev/devtools.js +6 -2
  55. package/src/server/dev/report.js +8 -1
  56. package/src/server/dev/version-check.mjs +139 -0
  57. package/src/server/head-hints.js +1 -1
  58. package/src/server/html-cache.js +32 -6
  59. package/src/server/middleware/csrf.js +134 -0
  60. package/src/server/prewarm.js +164 -19
  61. package/src/server/render.js +256 -20
  62. package/src/server/router.js +14 -7
  63. package/src/server/status-page.js +1 -1
  64. package/src/version.mjs +9 -4
  65. package/src/views/components/loader.js +1 -1
  66. package/src/views/helpers/tags.js +53 -1
@@ -15,21 +15,28 @@
15
15
  * headers() → [{ source, headers: [{ key, value }] }]
16
16
  * redirects() → [{ source, destination, permanent?, statusCode? }]
17
17
  * rewrites() → [{ source, destination }] | { beforeFiles?, afterFiles? }
18
- * cache() → { html?: { [source]: saniye }, prewarm?: {...} }
18
+ * cache() → { html?: { [source]: saniye }, maxEntries?: number,
19
+ * data?: {...}, prewarm?: {...} }
20
+ *
21
+ * Fonksiyon olmayan bölümler (`brand`, `security`, `static`, `navigation`…)
22
+ * düz nesne olarak okunur.
19
23
  */
20
24
  import fs from "node:fs";
21
25
  import path from "node:path";
22
26
  import process from "node:process";
23
27
  import { pathToFileURL } from "node:url";
24
- import { compilePattern } from "./pattern.js";
28
+ import { compilePattern, matchPattern } from "./pattern.js";
25
29
  import {
26
30
  DEFAULT_BRAND,
31
+ DEFAULT_DATA_CACHE,
27
32
  DEFAULT_DEV_GATE_BYPASS,
28
33
  DEFAULT_DIRS,
34
+ DEFAULT_HTML_CACHE_MAX_ENTRIES,
29
35
  DEFAULT_NAVIGATION,
30
36
  DEFAULT_NAVIGATION_EXCLUDE,
31
37
  DEFAULT_PREWARM,
32
38
  DEFAULT_PREWARM_SKIP,
39
+ DEFAULT_SECURITY,
33
40
  DEFAULT_STATIC,
34
41
  } from "./defaults.js";
35
42
 
@@ -59,7 +66,10 @@ const CONFIG_FILE = "jskelet.config.mjs";
59
66
  * @property {{ pattern: CompiledPattern, destination: string, statusCode: number }[]} redirects
60
67
  * @property {{ phase: "beforeFiles" | "afterFiles", pattern: CompiledPattern, destination: string }[]} rewrites
61
68
  * @property {{ pattern: CompiledPattern, seconds: number }[]} html
69
+ * @property {number} htmlMaxEntries HTML önbelleğinin girdi sınırı.
70
+ * @property {Record<string, unknown>} data Upstream veri önbelleği ayarları.
62
71
  * @property {Record<string, unknown>} prewarm
72
+ * @property {{ source: string, test: (pathname: string) => boolean }[]} prewarmPriority
63
73
  * @property {Record<string, unknown>} brand
64
74
  * @property {Record<string, Function>} hooks
65
75
  * @property {string} layout Layout `.ejs` dosyasının mutlak yolu.
@@ -68,6 +78,7 @@ const CONFIG_FILE = "jskelet.config.mjs";
68
78
  * @property {string[]} devGateBypass
69
79
  * @property {string[]} preconnect
70
80
  * @property {NavigationConfig} navigation
81
+ * @property {SecurityConfig} security
71
82
  * @property {string[]} prewarmSkip
72
83
  * @property {string[]} watch Dev sunucusunun izlediği ek dizinler.
73
84
  * @property {{ family: string, slug?: string, weights: number[] }[]} fonts
@@ -87,7 +98,7 @@ let config = null;
87
98
  function asArray(value, label) {
88
99
  if (value == null) return [];
89
100
  if (Array.isArray(value)) return value;
90
- console.warn(`[config] ${label} bir dizi döndürmeli, yok sayıldı`);
101
+ console.warn(`[config] ${label} must return an array, ignoring it`);
91
102
  return [];
92
103
  }
93
104
 
@@ -162,9 +173,41 @@ function normalizeRewrites(raw) {
162
173
  return out;
163
174
  }
164
175
 
176
+ /**
177
+ * Isıtma sırası desenleri. İki biçim kabul edilir: config'in her yerinde
178
+ * geçerli olan `/haber/:slug` sözdizimi ve doğrudan `RegExp` — ikincisi
179
+ * "sonu `-yorumlar` ile bitenler" gibi desen sözdiziminin karşılamadığı
180
+ * kuralları yazabilmek için.
181
+ *
182
+ * @param {unknown} raw
183
+ * @returns {ResolvedConfig["prewarmPriority"]}
184
+ */
185
+ function normalizePriority(raw) {
186
+ /** @type {ResolvedConfig["prewarmPriority"]} */
187
+ const out = [];
188
+
189
+ for (const entry of asArray(raw, "cache().prewarm.priority")) {
190
+ if (entry instanceof RegExp) {
191
+ out.push({ source: String(entry), test: (pathname) => entry.test(pathname) });
192
+ continue;
193
+ }
194
+
195
+ const pattern = compilePattern(entry);
196
+ if (!pattern) continue;
197
+ out.push({
198
+ source: pattern.source,
199
+ test: (pathname) => matchPattern(pattern, pathname) !== null,
200
+ });
201
+ }
202
+
203
+ return out;
204
+ }
205
+
165
206
  /**
166
207
  * @param {unknown} raw
167
- * @returns {{ html: ResolvedConfig["html"], prewarm: Record<string, unknown> }}
208
+ * @returns {{ html: ResolvedConfig["html"], htmlMaxEntries: number,
209
+ * data: Record<string, unknown>, prewarm: Record<string, unknown>,
210
+ * prewarmPriority: ResolvedConfig["prewarmPriority"] }}
168
211
  */
169
212
  function normalizeCache(raw) {
170
213
  /** @type {ResolvedConfig["html"]} */
@@ -177,7 +220,21 @@ function normalizeCache(raw) {
177
220
  html.push({ pattern, seconds: value });
178
221
  }
179
222
 
180
- return { html, prewarm: { ...DEFAULT_PREWARM, ...(raw?.prewarm ?? {}) } };
223
+ const prewarm = { ...DEFAULT_PREWARM, ...(raw?.prewarm ?? {}) };
224
+ const maxEntries = Number(raw?.maxEntries);
225
+
226
+ return {
227
+ html,
228
+ htmlMaxEntries:
229
+ Number.isFinite(maxEntries) && maxEntries > 0
230
+ ? Math.floor(maxEntries)
231
+ : DEFAULT_HTML_CACHE_MAX_ENTRIES,
232
+ data: { ...DEFAULT_DATA_CACHE, ...(raw?.data ?? {}) },
233
+ // Desenler derlenmiş hâlde ayrı alanda tutulur: `prewarm` sayısal
234
+ // ayarların düz torbası olarak kalsın, her turda yeniden derlenmesin.
235
+ prewarm,
236
+ prewarmPriority: normalizePriority(prewarm.priority),
237
+ };
181
238
  }
182
239
 
183
240
  /** Speculation Rules'un tanıdığı eagerness değerleri. */
@@ -201,7 +258,7 @@ function normalizeEagerness(value, fallback, label) {
201
258
  }
202
259
 
203
260
  console.warn(
204
- `[config] navigation.${label} geçersiz (${String(value)}), varsayılana dönüldü`,
261
+ `[config] navigation.${label} is invalid (${String(value)}), falling back to the default`,
205
262
  );
206
263
  return fallback;
207
264
  }
@@ -240,6 +297,50 @@ function normalizeNavigation(raw, brand) {
240
297
  };
241
298
  }
242
299
 
300
+ /**
301
+ * @typedef {object} SecurityConfig
302
+ * @property {boolean} trustProxy
303
+ * @property {string | null} cookieSecret
304
+ * @property {{ enabled: boolean, token: boolean, allowedOrigins: string[],
305
+ * exclude: CompiledPattern[], cookieName: string, fieldName: string,
306
+ * headerName: string }} csrf
307
+ */
308
+
309
+ /**
310
+ * Güvenlik bölümü. `csrf.exclude` desenleri burada derlenir: her istekte
311
+ * yeniden derlemek gereksiz, ve bozuk bir desen sunucuyu düşürmemeli.
312
+ *
313
+ * @param {unknown} raw
314
+ * @returns {SecurityConfig}
315
+ */
316
+ function normalizeSecurity(raw) {
317
+ const source = /** @type {Record<string, any>} */ (raw ?? {});
318
+ const csrf = { ...DEFAULT_SECURITY.csrf, ...(source.csrf ?? {}) };
319
+
320
+ const exclude = asArray(csrf.exclude, "security.csrf.exclude")
321
+ .map((entry) => compilePattern(entry))
322
+ .filter((pattern) => pattern !== null);
323
+
324
+ return {
325
+ trustProxy: source.trustProxy !== false,
326
+ cookieSecret:
327
+ typeof source.cookieSecret === "string" && source.cookieSecret
328
+ ? source.cookieSecret
329
+ : null,
330
+ csrf: {
331
+ enabled: csrf.enabled !== false,
332
+ token: csrf.token === true,
333
+ allowedOrigins: asArray(csrf.allowedOrigins, "security.csrf.allowedOrigins")
334
+ .filter((entry) => typeof entry === "string")
335
+ .map(String),
336
+ exclude: /** @type {CompiledPattern[]} */ (exclude),
337
+ cookieName: String(csrf.cookieName ?? DEFAULT_SECURITY.csrf.cookieName),
338
+ fieldName: String(csrf.fieldName ?? DEFAULT_SECURITY.csrf.fieldName),
339
+ headerName: String(csrf.headerName ?? DEFAULT_SECURITY.csrf.headerName).toLowerCase(),
340
+ },
341
+ };
342
+ }
343
+
243
344
  /**
244
345
  * Dizin adlarını mutlak yola çevirir. `styles` bir dosya yolu olduğu için
245
346
  * de aynı çözümlemeden geçer; ayrı bir alan tutmaya değmez.
@@ -307,7 +408,7 @@ export async function loadConfig(options = {}) {
307
408
 
308
409
  if (!fs.existsSync(configPath)) {
309
410
  console.warn(
310
- `[config] ${configFile} bulunamadı — yerleşik varsayılanlarla devam ediliyor.`,
411
+ `[config] ${configFile} not found — continuing with built-in defaults.`,
311
412
  );
312
413
  } else {
313
414
  try {
@@ -316,7 +417,7 @@ export async function loadConfig(options = {}) {
316
417
  source = module.default ?? module;
317
418
  loaded = true;
318
419
  } catch (error) {
319
- console.warn(`[config] ${configFile} yüklenemedi, yok sayıldı`, error);
420
+ console.warn(`[config] ${configFile} failed to load, ignoring it`, error);
320
421
  }
321
422
  }
322
423
 
@@ -327,7 +428,7 @@ export async function loadConfig(options = {}) {
327
428
  try {
328
429
  return typeof value === "function" ? await value.call(source) : value;
329
430
  } catch (error) {
330
- console.warn(`[config] ${name}() hata verdi, yok sayıldı`, error);
431
+ console.warn(`[config] ${name}() threw, ignoring it`, error);
331
432
  return null;
332
433
  }
333
434
  };
@@ -339,7 +440,8 @@ export async function loadConfig(options = {}) {
339
440
  section("cache"),
340
441
  ]);
341
442
 
342
- const { html, prewarm } = normalizeCache(cache);
443
+ const { html, htmlMaxEntries, data, prewarm, prewarmPriority } =
444
+ normalizeCache(cache);
343
445
  const dirs = resolveDirs(root, source.paths);
344
446
  const brand = { ...DEFAULT_BRAND, ...(source.brand ?? {}) };
345
447
 
@@ -351,7 +453,10 @@ export async function loadConfig(options = {}) {
351
453
  redirects: normalizeRedirects(redirects),
352
454
  rewrites: normalizeRewrites(rewrites),
353
455
  html,
456
+ htmlMaxEntries,
457
+ data,
354
458
  prewarm,
459
+ prewarmPriority,
355
460
  brand,
356
461
  hooks: source.hooks ?? {},
357
462
  layout: resolveLayout(dirs, source.layout),
@@ -363,6 +468,7 @@ export async function loadConfig(options = {}) {
363
468
  devGateBypass: source.devGateBypass ?? DEFAULT_DEV_GATE_BYPASS,
364
469
  preconnect: source.preconnect ?? [],
365
470
  navigation: normalizeNavigation(source.navigation, brand),
471
+ security: normalizeSecurity(source.security),
366
472
  prewarmSkip: source.prewarmSkip ?? DEFAULT_PREWARM_SKIP,
367
473
  // `routes`, `views` ve `lib` zaten izlenir; buraya yalnızca ek dizinler.
368
474
  watch: source.watch ?? [],
@@ -377,15 +483,20 @@ export async function loadConfig(options = {}) {
377
483
  // Dev'de build ve sunucu ayrı alt süreçler; üçü de aynı özeti basınca satır
378
484
  // banner'ın ve build bloğunun arasına üç kez giriyor. Özeti dış süreç basar.
379
485
  if (loaded && !process.env.JSKELET_CHILD) {
486
+ /** @param {number} count @param {string} singular @param {string} plural */
487
+ const label = (count, singular, plural) =>
488
+ `${count} ${count === 1 ? singular : plural}`;
489
+
380
490
  const counts = [
381
- config.headers.length && `${config.headers.length} header`,
382
- config.redirects.length && `${config.redirects.length} redirect`,
383
- config.rewrites.length && `${config.rewrites.length} rewrite`,
384
- config.html.length && `${config.html.length} cache kuralı`,
491
+ config.headers.length && label(config.headers.length, "header", "headers"),
492
+ config.redirects.length &&
493
+ label(config.redirects.length, "redirect", "redirects"),
494
+ config.rewrites.length && label(config.rewrites.length, "rewrite", "rewrites"),
495
+ config.html.length && label(config.html.length, "cache rule", "cache rules"),
385
496
  ].filter(Boolean);
386
497
 
387
498
  if (counts.length) {
388
- console.log(`[config] ${configFile} yüklendi — ${counts.join(", ")}`);
499
+ console.log(`[config] ${configFile} loaded — ${counts.join(", ")}`);
389
500
  }
390
501
  }
391
502
 
@@ -402,8 +513,8 @@ export async function loadConfig(options = {}) {
402
513
  export function getConfig() {
403
514
  if (!config) {
404
515
  throw new Error(
405
- "[config] loadConfig() çağrılmadan getConfig() kullanıldı. " +
406
- "Sunucuyu `jskelet` CLI ile ya da createApp() üzerinden başlatın.",
516
+ "[config] getConfig() was used before loadConfig(). " +
517
+ "Start the server with the `jskelet` CLI or through createApp().",
407
518
  );
408
519
  }
409
520
  return config;
@@ -427,7 +538,7 @@ export async function hook(name, fallback, ...args) {
427
538
  try {
428
539
  return await fn(...args);
429
540
  } catch (error) {
430
- console.warn(`[config] hooks.${name}() hata verdi, varsayılan kullanıldı`, error);
541
+ console.warn(`[config] hooks.${name}() threw, using the default`, error);
431
542
  return fallback;
432
543
  }
433
544
  }
@@ -28,7 +28,7 @@ function escapeLiteral(text) {
28
28
  */
29
29
  export function compilePattern(source) {
30
30
  if (typeof source !== "string" || !source.startsWith("/")) {
31
- console.warn(`[config] geçersiz source (\`/\` ile başlamalı): ${source}`);
31
+ console.warn(`[config] invalid source (must start with \`/\`): ${source}`);
32
32
  return null;
33
33
  }
34
34
 
@@ -350,7 +350,7 @@ function watchSources() {
350
350
  });
351
351
  } catch {
352
352
  log.warn(
353
- `${path.relative(ROOT, target)} izlenemedi; bu dizinde otomatik restart olmayacak.`,
353
+ `could not watch ${path.relative(ROOT, target)}; no auto restart for this directory.`,
354
354
  );
355
355
  }
356
356
  }
@@ -14,7 +14,7 @@ export class NotFoundError extends Error {
14
14
  export class RedirectError extends Error {
15
15
  /**
16
16
  * @param {string} location
17
- * @param {301 | 302 | 307 | 308} [statusCode]
17
+ * @param {301 | 302 | 303 | 307 | 308} [statusCode]
18
18
  */
19
19
  constructor(location, statusCode = 307) {
20
20
  super(`Redirect to ${location}`);
@@ -45,6 +45,21 @@ export function redirect(location) {
45
45
  throw new RedirectError(location, 307);
46
46
  }
47
47
 
48
+ /**
49
+ * POST sonrası yönlendirme (303 See Other).
50
+ *
51
+ * `redirect()` 307 kullanır ve 307 **metodu korur**: bir POST handler'ından
52
+ * çağrıldığında tarayıcı hedefe yeniden POST eder. Form gönderiminden sonra
53
+ * sayfayı GET olarak açmak — yani geri tuşunun formu yeniden göndermediği
54
+ * klasik "post/redirect/get" akışı — 303 gerektiriyor.
55
+ *
56
+ * @param {string} location
57
+ * @returns {never}
58
+ */
59
+ export function seeOther(location) {
60
+ throw new RedirectError(location, 303);
61
+ }
62
+
48
63
  /** @param {unknown} error */
49
64
  export function isNotFoundError(error) {
50
65
  return error instanceof NotFoundError;
@@ -0,0 +1,257 @@
1
+ /**
2
+ * Cookie okuma/yazma ve HMAC ile imzalama.
3
+ *
4
+ * Neden framework'te: kişiye özel her sayfa bir oturum cookie'sine dayanıyor
5
+ * ve bunu elle yazan her proje aynı üç hatayı tekrar ediyor — `HttpOnly`
6
+ * unutmak, imzasız değere güvenmek, karşılaştırmayı `===` ile yapmak.
7
+ * Burada varsayılanlar güvenli tarafta ve imza doğrulaması sabit zamanlı.
8
+ *
9
+ * Framework **kimlik sağlamaz**: oturumun içinde ne olduğu, ne kadar
10
+ * yaşadığı ve kimin verdiği uygulamanın kararı. Buradaki yüzey yalnızca
11
+ * "bu değeri ben yazdım, kurcalanmamış" garantisini veriyor.
12
+ *
13
+ * Bağımlılık eklenmez; `node:crypto` yeterli.
14
+ */
15
+ import crypto from "node:crypto";
16
+ import process from "node:process";
17
+ import { getConfig } from "../config/index.js";
18
+ import { markTainted } from "./request-context.js";
19
+
20
+ /** Ayrıştırılmış cookie'ler istek başına bir kez hesaplanır. */
21
+ const PARSED = Symbol("jskelet.cookies");
22
+
23
+ /**
24
+ * @param {string} value
25
+ * @returns {string}
26
+ */
27
+ function base64url(value) {
28
+ return Buffer.from(value, "utf8").toString("base64url");
29
+ }
30
+
31
+ /**
32
+ * @param {string} value
33
+ * @returns {string | null}
34
+ */
35
+ function fromBase64url(value) {
36
+ try {
37
+ return Buffer.from(value, "base64url").toString("utf8");
38
+ } catch {
39
+ return null;
40
+ }
41
+ }
42
+
43
+ /**
44
+ * İmza sırrı. `security.cookieSecret` ya da `JSKELET_SECRET`.
45
+ *
46
+ * Yokluğunda imzasız cookie yazmak en kötü sonuç olurdu: uygulama kendini
47
+ * güvende sanar, değer kurcalanabilir. Bu yüzden imzalı API sır olmadan
48
+ * hata verir — config hatasının siteyi düşürmemesi kuralı burada geçmez,
49
+ * çünkü sessiz alternatif bir güvenlik açığı.
50
+ *
51
+ * @returns {string}
52
+ */
53
+ function getSecret() {
54
+ /** @type {string | null} */
55
+ let configured = null;
56
+
57
+ try {
58
+ configured = getConfig().security.cookieSecret;
59
+ } catch {
60
+ // Config yüklenmemiş olabilir (script, test); env yine de geçerli.
61
+ configured = null;
62
+ }
63
+
64
+ const secret = configured ?? process.env.JSKELET_SECRET ?? null;
65
+
66
+ if (!secret) {
67
+ throw new Error(
68
+ "[cookies] a secret is required for signed cookies. Set " +
69
+ "`security.cookieSecret` in `jskelet.config.mjs` or the JSKELET_SECRET environment variable.",
70
+ );
71
+ }
72
+
73
+ return secret;
74
+ }
75
+
76
+ /**
77
+ * @param {string} value
78
+ * @returns {string}
79
+ */
80
+ function sign(value) {
81
+ return crypto.createHmac("sha256", getSecret()).update(value).digest("base64url");
82
+ }
83
+
84
+ /**
85
+ * Sabit zamanlı karşılaştırma: imza doğrulamasında erken çıkış, saldırganın
86
+ * baytları tek tek tahmin etmesine kapı aralar.
87
+ *
88
+ * @param {string} a
89
+ * @param {string} b
90
+ * @returns {boolean}
91
+ */
92
+ export function safeEqual(a, b) {
93
+ const left = Buffer.from(String(a));
94
+ const right = Buffer.from(String(b));
95
+ if (left.length !== right.length) return false;
96
+ return crypto.timingSafeEqual(left, right);
97
+ }
98
+
99
+ /**
100
+ * `Cookie` başlığını ayrıştırır.
101
+ *
102
+ * Okuma çıktının kullanıcıya bağlı olduğunu bildirir: bu sayfa artık public
103
+ * HTML cache'ine yazılamaz.
104
+ *
105
+ * @param {import('http').IncomingMessage} req
106
+ * @returns {Record<string, string>}
107
+ */
108
+ export function parseCookies(req) {
109
+ markTainted("parseCookies(req)");
110
+
111
+ const cached = /** @type {any} */ (req)[PARSED];
112
+ if (cached) return cached;
113
+
114
+ /** @type {Record<string, string>} */
115
+ const out = {};
116
+ const header = req.headers?.cookie;
117
+
118
+ if (header) {
119
+ for (const part of header.split(";")) {
120
+ const index = part.indexOf("=");
121
+ if (index === -1) continue;
122
+
123
+ const name = part.slice(0, index).trim();
124
+ if (!name) continue;
125
+
126
+ try {
127
+ out[name] = decodeURIComponent(part.slice(index + 1).trim());
128
+ } catch {
129
+ // Bozuk yüzde kodlaması tüm başlığı çöpe atmamalı.
130
+ out[name] = part.slice(index + 1).trim();
131
+ }
132
+ }
133
+ }
134
+
135
+ /** @type {any} */ (req)[PARSED] = out;
136
+ return out;
137
+ }
138
+
139
+ /**
140
+ * @typedef {object} CookieOptions
141
+ * @property {string} [path] Varsayılan `/`.
142
+ * @property {string} [domain]
143
+ * @property {number} [maxAge] Saniye.
144
+ * @property {Date} [expires]
145
+ * @property {boolean} [httpOnly] Varsayılan `true`.
146
+ * @property {boolean} [secure] Varsayılan: development dışında `true`.
147
+ * @property {"Strict" | "Lax" | "None"} [sameSite] Varsayılan `Lax`.
148
+ */
149
+
150
+ /**
151
+ * Varsayılanlar bilinçli olarak kısıtlayıcı: `HttpOnly` ile JS okuyamaz,
152
+ * `SameSite=Lax` ile çapraz site POST'larında gönderilmez (CSRF'nin büyük
153
+ * kısmını kapatan tek satır), `Secure` üretimde açık.
154
+ *
155
+ * @param {string} name
156
+ * @param {string} value
157
+ * @param {CookieOptions} [options]
158
+ * @returns {string}
159
+ */
160
+ export function serializeCookie(name, value, options = {}) {
161
+ const parts = [`${name}=${encodeURIComponent(value)}`];
162
+
163
+ parts.push(`Path=${options.path ?? "/"}`);
164
+ if (options.domain) parts.push(`Domain=${options.domain}`);
165
+ if (options.maxAge !== undefined) parts.push(`Max-Age=${Math.floor(options.maxAge)}`);
166
+ if (options.expires) parts.push(`Expires=${options.expires.toUTCString()}`);
167
+ if (options.httpOnly !== false) parts.push("HttpOnly");
168
+ if (options.secure ?? process.env.NODE_ENV !== "development") parts.push("Secure");
169
+ parts.push(`SameSite=${options.sameSite ?? "Lax"}`);
170
+
171
+ return parts.join("; ");
172
+ }
173
+
174
+ /**
175
+ * @param {import('http').ServerResponse} res
176
+ * @param {string} name
177
+ * @param {string} value
178
+ * @param {CookieOptions} [options]
179
+ */
180
+ export function setCookie(res, name, value, options = {}) {
181
+ const existing = res.getHeader("Set-Cookie");
182
+ const serialized = serializeCookie(name, value, options);
183
+
184
+ /** @type {string[]} */
185
+ const all = existing
186
+ ? Array.isArray(existing)
187
+ ? [...existing.map(String)]
188
+ : [String(existing)]
189
+ : [];
190
+
191
+ all.push(serialized);
192
+ res.setHeader("Set-Cookie", all);
193
+ }
194
+
195
+ /**
196
+ * @param {import('http').ServerResponse} res
197
+ * @param {string} name
198
+ * @param {CookieOptions} [options]
199
+ */
200
+ export function clearCookie(res, name, options = {}) {
201
+ setCookie(res, name, "", { ...options, maxAge: 0, expires: new Date(0) });
202
+ }
203
+
204
+ /**
205
+ * İmzalı cookie yazar. Değer okunabilir kalır (şifreleme değil, imza);
206
+ * gizli kalması gereken veriyi cookie'ye koymayın, kimliğini koyun.
207
+ *
208
+ * @param {import('http').ServerResponse} res
209
+ * @param {string} name
210
+ * @param {string} value
211
+ * @param {CookieOptions} [options]
212
+ */
213
+ export function setSignedCookie(res, name, value, options = {}) {
214
+ const encoded = base64url(value);
215
+ setCookie(res, name, `${encoded}.${sign(encoded)}`, options);
216
+ }
217
+
218
+ /**
219
+ * İmzalı cookie okur. İmza uymuyorsa `null` — bozuk imza, yok sayılmalı,
220
+ * "belki geçerlidir" diye kullanılmamalı.
221
+ *
222
+ * @param {import('http').IncomingMessage} req
223
+ * @param {string} name
224
+ * @returns {string | null}
225
+ */
226
+ export function getSignedCookie(req, name) {
227
+ const raw = parseCookies(req)[name];
228
+ if (!raw) return null;
229
+
230
+ const index = raw.lastIndexOf(".");
231
+ if (index <= 0) return null;
232
+
233
+ const encoded = raw.slice(0, index);
234
+ const signature = raw.slice(index + 1);
235
+
236
+ let expected;
237
+ try {
238
+ expected = sign(encoded);
239
+ } catch {
240
+ // Sır yoksa imzalı okuma sessizce başarısız olur: sunucu ayakta kalır
241
+ // ama hiçbir oturum geçerli sayılmaz.
242
+ return null;
243
+ }
244
+
245
+ if (!safeEqual(signature, expected)) return null;
246
+ return fromBase64url(encoded);
247
+ }
248
+
249
+ /**
250
+ * Kriptografik rastgele token. CSRF token'ı ve oturum kimliği için.
251
+ *
252
+ * @param {number} [bytes]
253
+ * @returns {string}
254
+ */
255
+ export function randomToken(bytes = 32) {
256
+ return crypto.randomBytes(bytes).toString("base64url");
257
+ }