jskelet 0.2.1 → 0.2.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.
@@ -704,6 +704,21 @@ const CSS = `
704
704
  border-radius: 6px; padding: 1px 6px; font-family: ui-monospace, monospace;
705
705
  }
706
706
 
707
+ /* Yüklü sürüm ve kayıt defterindeki son sürüm. */
708
+ .ver {
709
+ display: inline-flex; align-items: center; gap: 6px;
710
+ font-size: 10.5px; font-weight: 600; color: var(--muted);
711
+ border: 1px solid var(--line); border-radius: 999px; padding: 2px 8px;
712
+ font-family: ui-monospace, SFMono-Regular, monospace;
713
+ font-variant-numeric: tabular-nums;
714
+ }
715
+ .ver .cur { color: #dbe2ea; }
716
+ .ver .state { text-transform: uppercase; letter-spacing: .05em; font-size: 9.5px; }
717
+ .ver.latest .state { color: var(--good); }
718
+ .ver.outdated { border-color: rgba(251,191,36,.35); }
719
+ .ver.outdated .state { color: var(--mid); }
720
+ .ver .next { color: var(--mid); }
721
+
707
722
  .rail {
708
723
  display: flex; flex-direction: column; gap: 4px; padding: 14px 10px;
709
724
  border-inline-end: 1px solid var(--line); background: rgba(0,0,0,.22);
@@ -893,6 +908,7 @@ const SKELETON = `
893
908
  <div class="head">
894
909
  <img src="${BASE}/logo.png" alt="">
895
910
  <span class="title">JSkelet Dev</span>
911
+ <span class="ver" data-part="version" hidden></span>
896
912
  <span class="pill"><span class="dot"></span><span data-part="pill"></span></span>
897
913
  <span class="spacer"></span>
898
914
  <span class="kbd">Alt+D</span>
@@ -1291,6 +1307,7 @@ function serverTab() {
1291
1307
  ]
1292
1308
  : []),
1293
1309
  ])}
1310
+ ${upstreamSection()}
1294
1311
  <h4>Recent requests</h4>
1295
1312
  ${
1296
1313
  requests.length
@@ -1309,6 +1326,39 @@ function serverTab() {
1309
1326
  `;
1310
1327
  }
1311
1328
 
1329
+ /**
1330
+ * Upstream hız freninin durumu. Fren kapalıysa (varsayılan) hiçbir şey
1331
+ * basılmaz; açıkken 429 fırtınasında hızın nereye indiğini görmek, ayarı
1332
+ * körlemesine yapmamanın tek yolu.
1333
+ *
1334
+ * @returns {string}
1335
+ */
1336
+ function upstreamSection() {
1337
+ const hosts = serverStats?.upstream ?? [];
1338
+ if (!hosts.length) return "";
1339
+
1340
+ return `
1341
+ <h4>Upstream rate limit</h4>
1342
+ <div class="rows">${hosts
1343
+ .map((host) => {
1344
+ const throttled = host.rate < host.maxRate;
1345
+ const state = host.bypassed
1346
+ ? "bypassed"
1347
+ : host.blockedMs
1348
+ ? `waiting ${Math.ceil(host.blockedMs / 1000)} s`
1349
+ : `${host.active} in flight`;
1350
+
1351
+ return `<div class="row">
1352
+ <span class="status ${host.bypassed ? "bad" : throttled ? "warn" : ""}">${host.rate}/s</span>
1353
+ <span class="path">${escapeHtml(host.host)}</span>
1354
+ <span class="hint">${escapeHtml(state)}</span>
1355
+ <span class="${host.throttled ? "mid" : ""}">${host.throttled} × 429</span>
1356
+ </div>`;
1357
+ })
1358
+ .join("")}</div>
1359
+ `;
1360
+ }
1361
+
1312
1362
  function warmTab() {
1313
1363
  const warm = serverStats?.prewarm;
1314
1364
 
@@ -1647,6 +1697,40 @@ function paintPrewarm(root) {
1647
1697
  if (!warm.active) setTimeout(render, WARM_LINGER_MS);
1648
1698
  }
1649
1699
 
1700
+ /**
1701
+ * Başlıktaki sürüm göstergesi: solda yüklü sürüm, sağda kayıt defterindeki
1702
+ * durum. Kontrol kapalıysa ya da kayıt defterine ulaşılamadıysa (`latest`
1703
+ * boş) yalnızca yüklü sürüm yazılır; "bilinmiyor" etiketi gürültü olurdu.
1704
+ *
1705
+ * @param {ShadowRoot} root
1706
+ * @param {{ current: string, latest: string | null, outdated: boolean } | undefined} version
1707
+ */
1708
+ function paintVersion(root, version) {
1709
+ const node = root.querySelector("[data-part='version']");
1710
+
1711
+ if (!version?.current) {
1712
+ node.hidden = true;
1713
+ return;
1714
+ }
1715
+
1716
+ const { current, latest, outdated } = version;
1717
+ node.hidden = false;
1718
+ node.className = `ver ${latest ? (outdated ? "outdated" : "latest") : ""}`;
1719
+ node.title = latest
1720
+ ? outdated
1721
+ ? `Installed ${current}, npm has ${latest}. Run: npm install jskelet@latest`
1722
+ : `Installed ${current} — up to date with npm`
1723
+ : `Installed ${current}. The npm registry could not be reached.`;
1724
+
1725
+ node.innerHTML =
1726
+ `<span class="cur">v${escapeHtml(current)}</span>` +
1727
+ (latest
1728
+ ? outdated
1729
+ ? `<span class="state">outdated</span><span class="next">→ v${escapeHtml(latest)}</span>`
1730
+ : `<span class="state">latest</span>`
1731
+ : "");
1732
+ }
1733
+
1650
1734
  function paint() {
1651
1735
  const root = ensureRoot();
1652
1736
 
@@ -1690,6 +1774,7 @@ function paint() {
1690
1774
  chip.textContent = String(errorCount);
1691
1775
 
1692
1776
  const version = serverStats?.version;
1777
+ paintVersion(root, version);
1693
1778
  const versionChip = root.querySelector("[data-part='version-chip']");
1694
1779
  versionChip.hidden = !version?.outdated;
1695
1780
  if (version?.outdated) versionChip.title = `JSkelet ${version.latest} is available`;
@@ -228,6 +228,19 @@ function overview() {
228
228
  tone: apiErrors ? "bad" : "good",
229
229
  sub: apiErrors ? `${apiErrors} errors` : "no errors",
230
230
  },
231
+ // Upstream kotasının nasıl harcandığı: okumaların kaçı önbellekten
232
+ // döndü. Oran düşükse `withDataCache` TTL'i kısa demektir.
233
+ {
234
+ label: "Data cache",
235
+ value: data.cache?.dataStats?.reads
236
+ ? `${Math.round(data.cache.dataStats.hitRatio * 100)}%`
237
+ : "—",
238
+ ratio: data.cache?.dataStats?.hitRatio ?? null,
239
+ tone: "good",
240
+ sub: data.cache?.dataStats?.reads
241
+ ? `${data.cache.dataStats.produced} upstream / ${data.cache.dataStats.reads} reads`
242
+ : "no reads yet",
243
+ },
231
244
  {
232
245
  label: "Prewarm",
233
246
  value: data.prewarm?.total
@@ -120,6 +120,42 @@ export const DEFAULT_DATA_CACHE = {
120
120
  staleFactor: 10,
121
121
  };
122
122
 
123
+ /**
124
+ * Upstream API'ye giden isteklerin host başına hız freni.
125
+ *
126
+ * Varsayılan **kapalı** (`rate: 0`): fren, kotasını bilen bir uygulamanın
127
+ * bilinçli kararı. Açıldığında `rate` bir tavan olur ve gerçek hız 429
128
+ * cevaplarına göre kendini aşağı çeker (bkz. `src/server/upstream-limiter.js`).
129
+ *
130
+ * `hosts` ile tek tek uçlar ayrılabilir; API'lerin kotası aynı olmak zorunda
131
+ * değil:
132
+ *
133
+ * upstream: { rate: 10, hosts: { "api.example.com": { rate: 3 } } }
134
+ */
135
+ export const DEFAULT_UPSTREAM_LIMIT = {
136
+ /** Saniyedeki en fazla çağrı. 0 → fren tamamen kapalı. */
137
+ rate: 0,
138
+ /** Kova boyu; verilmezse bir saniyelik bütçe kadar patlamaya izin verilir. */
139
+ burst: 0,
140
+ /** Aynı anda uçabilecek çağrı. Ortalama hızdan bağımsız: anlık baskıyı bağlar. */
141
+ concurrency: 8,
142
+ /** Azalmanın dibi: hız buranın altına inmez, yoksa site tamamen durur. */
143
+ minRate: 0.5,
144
+ /** Toplamsal artışın adımı (çağrı/saniye) ve periyodu. */
145
+ increaseStep: 1,
146
+ increaseIntervalMs: 5000,
147
+ /**
148
+ * İki azalma arasındaki en kısa süre. Aynı anda uçan on çağrının hepsi 429
149
+ * dönerse hız on kez yarılanıp dibe vurmasın.
150
+ */
151
+ decreaseIntervalMs: 1000,
152
+ /** Devre kesici: art arda kaç 429'dan sonra host'a hiç gidilmeyeceği. */
153
+ breakerFailures: 5,
154
+ breakerCooldownMs: 10_000,
155
+ /** @type {Record<string, Record<string, number>>} */
156
+ hosts: {},
157
+ };
158
+
123
159
  /**
124
160
  * Opsiyonel Redis ikinci kademesi (L2).
125
161
  *
@@ -155,8 +191,68 @@ export const DEFAULT_REDIS = {
155
191
  commandTimeoutMs: 200,
156
192
  };
157
193
 
194
+ /**
195
+ * Önbellek yönetim paneli.
196
+ *
197
+ * `enabled` varsayılan olarak **kapalı** ve ortama bakmaz: panel açıldığında
198
+ * production'da da çalışır, ama açılması bilinçli bir karar olmalı. Kapalıyken
199
+ * router hiç mount edilmez — yolun kendisi de yok, yani 404 dönen bir uç bile
200
+ * ortaya çıkmaz.
201
+ *
202
+ * Şifre her süreç başlangıcında yeniden üretilir (bkz.
203
+ * `src/server/cache-panel.js`): panelin ömrü sürecin ömrü kadardır ve bir
204
+ * deploy eski erişimi otomatik olarak iptal eder. Bu yüzden config'te şifre
205
+ * alanı yok.
206
+ */
207
+ export const DEFAULT_CACHE_PANEL = {
208
+ enabled: false,
209
+ basePath: "/_jskelet/cache",
210
+ /** Kaç başarısız denemeden sonra IP yasaklanır. */
211
+ banAttempts: 3,
212
+ /** Yasağın süresi. */
213
+ banHours: 24,
214
+ /** Oturumun ömrü; süreç yeniden başladığında zaten sıfırlanır. */
215
+ sessionHours: 12,
216
+ };
217
+
218
+ /**
219
+ * Cloudflare cache yüzeyi.
220
+ *
221
+ * JSkelet'in önbelleği origin önbelleği; ziyaretçinin gördüğü kopya CDN'de.
222
+ * Bu bölüm ikisini aynı panelden yönetilebilir kılar (bkz.
223
+ * `src/server/cloudflare.js`).
224
+ *
225
+ * Token **config'e yazılmamalı**: `JSKELET_CLOUDFLARE_KEY` env'i önceliklidir
226
+ * ve önerilen yol odur. Zone kimliği sır değil, ama o da env'den okunabilir
227
+ * (`JSKELET_CLOUDFLARE_ZONE_ID`).
228
+ *
229
+ * Gereken token izinleri: purge için `Zone.Cache Purge`, ayarlar için
230
+ * `Zone.Zone Settings`, analitik için `Zone.Analytics` (salt okunur).
231
+ */
232
+ export const DEFAULT_CLOUDFLARE = {
233
+ /** `false` verilirse env'de token olsa bile yüzey kapalı kalır. */
234
+ enabled: true,
235
+ /** @type {string | null} */
236
+ zoneId: null,
237
+ /** @type {string | null} Env tercih edilir; burada tutmak sırrı repoya sokar. */
238
+ apiToken: null,
239
+ /**
240
+ * Purge, tam URL istiyor; panel elinde yalnızca yol tutuyor. Site adı
241
+ * verilmezse purge isteğinin geldiği istek origin'i kullanılır.
242
+ * @type {string | null}
243
+ */
244
+ hostname: null,
245
+ /** Analitik penceresi (saat). Cloudflare'in izin verdiği aralıkla sınırlı. */
246
+ analyticsHours: 24,
247
+ };
248
+
158
249
  /** Oturuma bağlı sayfalar ısıtılmaz; uygulama kendi listesini verebilir. */
159
- export const DEFAULT_PREWARM_SKIP = ["/api/", "/_fragment/", "/__jskelet/"];
250
+ export const DEFAULT_PREWARM_SKIP = [
251
+ "/api/",
252
+ "/_fragment/",
253
+ "/__jskelet/",
254
+ "/_jskelet/",
255
+ ];
160
256
 
161
257
  /**
162
258
  * Site içi gezinme ipuçları (Speculation Rules + view transition).
@@ -182,7 +278,7 @@ export const DEFAULT_NAVIGATION = {
182
278
  * hedefi olmayan uçların spekülatif istekle tetiklenmesi gerçek bir hata
183
279
  * kaynağı; uygulama kendi listesini `navigation.exclude` ile ekler.
184
280
  */
185
- export const DEFAULT_NAVIGATION_EXCLUDE = ["/api/*", "/_fragment/*"];
281
+ export const DEFAULT_NAVIGATION_EXCLUDE = ["/api/*", "/_fragment/*", "/_jskelet/*"];
186
282
 
187
283
  /**
188
284
  * Güvenlik ayarları.
@@ -15,8 +15,10 @@
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 }, maxEntries?: number,
19
- * data?: {...}, redis?: {...}, prewarm?: {...} }
18
+ * cache() → { html?: { [source]: saniye },
19
+ * query?: { [source]: string[] | true }, maxEntries?: number,
20
+ * data?: {...}, redis?: {...}, prewarm?: {...},
21
+ * panel?: {...} }
20
22
  *
21
23
  * Fonksiyon olmayan bölümler (`brand`, `security`, `static`, `navigation`…)
22
24
  * düz nesne olarak okunur.
@@ -28,6 +30,8 @@ import { pathToFileURL } from "node:url";
28
30
  import { compilePattern, matchPattern } from "./pattern.js";
29
31
  import {
30
32
  DEFAULT_BRAND,
33
+ DEFAULT_CACHE_PANEL,
34
+ DEFAULT_CLOUDFLARE,
31
35
  DEFAULT_DATA_CACHE,
32
36
  DEFAULT_DEV_GATE_BYPASS,
33
37
  DEFAULT_DIRS,
@@ -40,6 +44,7 @@ import {
40
44
  DEFAULT_SECURITY,
41
45
  DEFAULT_STATIC,
42
46
  DEFAULT_TRANSIENT_RETRY,
47
+ DEFAULT_UPSTREAM_LIMIT,
43
48
  } from "./defaults.js";
44
49
 
45
50
  /** Framework paketinin kökü — kendi şablonlarına ve varlıklarına erişir. */
@@ -81,12 +86,18 @@ const CONFIG_FILE = "jskelet.config.mjs";
81
86
  * @property {{ pattern: CompiledPattern, destination: string, statusCode: number }[]} redirects
82
87
  * @property {{ phase: "beforeFiles" | "afterFiles", pattern: CompiledPattern, destination: string }[]} rewrites
83
88
  * @property {{ pattern: CompiledPattern, seconds: number }[]} html
89
+ * @property {{ pattern: CompiledPattern, allow: true | string[] }[]} cacheQuery
90
+ * Yol deseni başına, HTML cache anahtarına girmesine izin verilen query
91
+ * parametreleri. Eşleşen kural yoksa query'li istek cache'lenmez.
84
92
  * @property {number} htmlMaxEntries HTML önbelleğinin girdi sınırı.
85
93
  * @property {Record<string, unknown>} data Upstream veri önbelleği ayarları.
86
94
  * @property {boolean} trackUpstream `fetch` sarılıp geçici hatalar otomatik bildirilsin mi.
87
95
  * @property {boolean} trackDependencies Render'ın okuduğu veri anahtarları kaydedilsin mi.
88
96
  * @property {{ attempts: number, delayMs: number }} transientRetry
89
97
  * @property {RedisConfig} redis Opsiyonel Redis ikinci kademesi.
98
+ * @property {typeof DEFAULT_UPSTREAM_LIMIT} upstream Upstream hız freni.
99
+ * @property {typeof DEFAULT_CACHE_PANEL} cachePanel Önbellek yönetim paneli.
100
+ * @property {typeof DEFAULT_CLOUDFLARE} cloudflare Cloudflare cache yüzeyi.
90
101
  * @property {Record<string, unknown>} prewarm
91
102
  * @property {{ source: string, test: (pathname: string) => boolean }[]} prewarmPriority
92
103
  * @property {Record<string, unknown>} brand
@@ -249,13 +260,171 @@ function normalizeRedis(raw) {
249
260
  };
250
261
  }
251
262
 
263
+ /**
264
+ * Upstream hız freni. Sayısal alanlar tipine zorlanır; bozuk bir değer freni
265
+ * yanlış ayarlamak yerine varsayılana döner.
266
+ *
267
+ * @param {unknown} raw
268
+ * @returns {typeof DEFAULT_UPSTREAM_LIMIT}
269
+ */
270
+ function normalizeUpstream(raw) {
271
+ const source = /** @type {Record<string, any>} */ (raw ?? {});
272
+ const merged = { ...DEFAULT_UPSTREAM_LIMIT, ...source };
273
+
274
+ /** @param {string} key */
275
+ const positive = (key) => {
276
+ const value = Number(merged[key]);
277
+ return Number.isFinite(value) && value >= 0
278
+ ? value
279
+ : /** @type {any} */ (DEFAULT_UPSTREAM_LIMIT)[key];
280
+ };
281
+
282
+ /** @type {Record<string, Record<string, number>>} */
283
+ const hosts = {};
284
+ for (const [host, override] of Object.entries(merged.hosts ?? {})) {
285
+ if (override && typeof override === "object") hosts[host] = override;
286
+ }
287
+
288
+ return {
289
+ ...merged,
290
+ rate: positive("rate"),
291
+ burst: positive("burst"),
292
+ concurrency: Math.max(1, Math.floor(positive("concurrency"))),
293
+ minRate: positive("minRate"),
294
+ increaseStep: positive("increaseStep"),
295
+ increaseIntervalMs: positive("increaseIntervalMs"),
296
+ decreaseIntervalMs: positive("decreaseIntervalMs"),
297
+ breakerFailures: Math.floor(positive("breakerFailures")),
298
+ breakerCooldownMs: positive("breakerCooldownMs"),
299
+ hosts,
300
+ };
301
+ }
302
+
303
+ /**
304
+ * Önbellek panelinin bölümü.
305
+ *
306
+ * `enabled` yalnızca açıkça `true` verildiğinde ya da `JSKELET_CACHE_PANEL`
307
+ * ortam değişkeni ayarlandığında açılır: paneli yanlışlıkla açmanın bedeli,
308
+ * önbelleği boşaltabilen bir ucu internete koymak.
309
+ *
310
+ * Ortam değişkeni config'in **üstünde** duruyor, çünkü paneli genelde bir
311
+ * arıza sırasında tek seferlik açmak isteniyor ve o an config dosyasını
312
+ * değiştirip yeniden dağıtmak istenmiyor. `JSKELET_CACHE_PANEL=0` aynı
313
+ * mantıkla config'te açık olan paneli kapatır.
314
+ *
315
+ * @param {unknown} raw
316
+ * @returns {typeof DEFAULT_CACHE_PANEL}
317
+ */
318
+ function normalizeCachePanel(raw) {
319
+ const source = /** @type {Record<string, any>} */ (raw ?? {});
320
+ const env = process.env.JSKELET_CACHE_PANEL;
321
+
322
+ const basePath =
323
+ typeof source.basePath === "string" && source.basePath.startsWith("/")
324
+ ? source.basePath.replace(/\/+$/, "")
325
+ : DEFAULT_CACHE_PANEL.basePath;
326
+
327
+ /** @param {string} key @param {number} min */
328
+ const positive = (key, min) => {
329
+ const value = Number(source[key]);
330
+ return Number.isFinite(value) && value >= min
331
+ ? value
332
+ : /** @type {any} */ (DEFAULT_CACHE_PANEL)[key];
333
+ };
334
+
335
+ return {
336
+ enabled:
337
+ env === undefined
338
+ ? source.enabled === true
339
+ : env !== "0" && env !== "false" && env !== "",
340
+ basePath: basePath || DEFAULT_CACHE_PANEL.basePath,
341
+ banAttempts: Math.floor(positive("banAttempts", 1)),
342
+ banHours: positive("banHours", 0),
343
+ sessionHours: positive("sessionHours", 0),
344
+ };
345
+ }
346
+
347
+ /**
348
+ * Cloudflare bölümü. Token burada da verilebiliyor ama önerilen yol env;
349
+ * normalizasyon sadece tipleri sabitler, sırrı okumak `cloudflare.js`'in işi.
350
+ *
351
+ * @param {unknown} raw
352
+ * @returns {typeof DEFAULT_CLOUDFLARE}
353
+ */
354
+ function normalizeCloudflare(raw) {
355
+ const source = /** @type {Record<string, any>} */ (raw ?? {});
356
+ const hours = Number(source.analyticsHours);
357
+
358
+ /** @param {unknown} value */
359
+ const text = (value) => (typeof value === "string" && value ? value : null);
360
+
361
+ return {
362
+ enabled: source.enabled !== false,
363
+ zoneId: text(source.zoneId),
364
+ apiToken: text(source.apiToken),
365
+ // Şema yazılırsa purge URL'i `https://https://…` olur; baştaki şema atılır.
366
+ hostname: text(source.hostname)?.replace(/^https?:\/\//, "") ?? null,
367
+ analyticsHours:
368
+ Number.isFinite(hours) && hours > 0
369
+ ? Math.min(72, Math.floor(hours))
370
+ : DEFAULT_CLOUDFLARE.analyticsHours,
371
+ };
372
+ }
373
+
374
+ /**
375
+ * `cache().query` → yol deseni başına, cache anahtarına girmesine izin verilen
376
+ * query parametreleri.
377
+ *
378
+ * Varsayılan bilinçli olarak "query varsa sayfa dinamik": bir yolun bütün
379
+ * query varyantlarını cache'lemek, `?utm_source=…` gibi sonsuz sayıda anahtar
380
+ * üretip LRU'daki gerçek sayfaları dışarı atıyor. Hangi parametrenin çıktıyı
381
+ * gerçekten değiştirdiğini yalnızca uygulama bilir, o yüzden izin listesi
382
+ * config'ten gelir.
383
+ *
384
+ * Bir desen `true` ile eşlenirse bütün parametreler anahtara girer (eski
385
+ * davranış), `[]` ile eşlenirse hiçbiri girmez — yani query yok sayılır ve
386
+ * bütün varyantlar query'siz sürümün HTML'ini paylaşır.
387
+ *
388
+ * @param {unknown} raw
389
+ * @returns {ResolvedConfig["cacheQuery"]}
390
+ */
391
+ function normalizeQueryRules(raw) {
392
+ /** @type {ResolvedConfig["cacheQuery"]} */
393
+ const out = [];
394
+
395
+ for (const [source, value] of Object.entries(raw ?? {})) {
396
+ const pattern = compilePattern(source);
397
+ if (!pattern) continue;
398
+
399
+ if (value === true) {
400
+ out.push({ pattern, allow: true });
401
+ continue;
402
+ }
403
+ if (value === false) continue;
404
+
405
+ const allow = asArray(
406
+ typeof value === "string" ? [value] : value,
407
+ `cache().query["${source}"]`,
408
+ )
409
+ .filter((name) => typeof name === "string" && name)
410
+ .map(String);
411
+ out.push({ pattern, allow });
412
+ }
413
+
414
+ return out;
415
+ }
416
+
252
417
  /**
253
418
  * @param {unknown} raw
254
- * @returns {{ html: ResolvedConfig["html"], htmlMaxEntries: number,
419
+ * @returns {{ html: ResolvedConfig["html"],
420
+ * cacheQuery: ResolvedConfig["cacheQuery"], htmlMaxEntries: number,
255
421
  * data: Record<string, unknown>, trackUpstream: boolean,
256
422
  * trackDependencies: boolean,
257
423
  * transientRetry: { attempts: number, delayMs: number },
258
424
  * redis: RedisConfig,
425
+ * upstream: typeof DEFAULT_UPSTREAM_LIMIT,
426
+ * cachePanel: typeof DEFAULT_CACHE_PANEL,
427
+ * cloudflare: typeof DEFAULT_CLOUDFLARE,
259
428
  * prewarm: Record<string, unknown>,
260
429
  * prewarmPriority: ResolvedConfig["prewarmPriority"] }}
261
430
  */
@@ -271,10 +440,12 @@ function normalizeCache(raw) {
271
440
  }
272
441
 
273
442
  const prewarm = { ...DEFAULT_PREWARM, ...(raw?.prewarm ?? {}) };
443
+ const queryRules = normalizeQueryRules(raw?.query);
274
444
  const maxEntries = Number(raw?.maxEntries);
275
445
 
276
446
  return {
277
447
  html,
448
+ cacheQuery: queryRules,
278
449
  htmlMaxEntries:
279
450
  Number.isFinite(maxEntries) && maxEntries > 0
280
451
  ? Math.floor(maxEntries)
@@ -292,6 +463,9 @@ function normalizeCache(raw) {
292
463
  ? { attempts: 0, delayMs: 0 }
293
464
  : { ...DEFAULT_TRANSIENT_RETRY, ...(raw?.transientRetry ?? {}) },
294
465
  redis: normalizeRedis(raw?.redis),
466
+ upstream: normalizeUpstream(raw?.upstream),
467
+ cachePanel: normalizeCachePanel(raw?.panel),
468
+ cloudflare: normalizeCloudflare(raw?.cloudflare),
295
469
  // Desenler derlenmiş hâlde ayrı alanda tutulur: `prewarm` sayısal
296
470
  // ayarların düz torbası olarak kalsın, her turda yeniden derlenmesin.
297
471
  prewarm,
@@ -504,12 +678,16 @@ export async function loadConfig(options = {}) {
504
678
 
505
679
  const {
506
680
  html,
681
+ cacheQuery,
507
682
  htmlMaxEntries,
508
683
  data,
509
684
  trackUpstream,
510
685
  trackDependencies,
511
686
  transientRetry,
512
687
  redis,
688
+ upstream,
689
+ cachePanel,
690
+ cloudflare,
513
691
  prewarm,
514
692
  prewarmPriority,
515
693
  } = normalizeCache(cache);
@@ -524,12 +702,16 @@ export async function loadConfig(options = {}) {
524
702
  redirects: normalizeRedirects(redirects),
525
703
  rewrites: normalizeRewrites(rewrites),
526
704
  html,
705
+ cacheQuery,
527
706
  htmlMaxEntries,
528
707
  data,
529
708
  trackUpstream,
530
709
  trackDependencies,
531
710
  transientRetry,
532
711
  redis,
712
+ upstream,
713
+ cachePanel,
714
+ cloudflare,
533
715
  prewarm,
534
716
  prewarmPriority,
535
717
  brand,
package/src/index.js CHANGED
@@ -41,6 +41,7 @@ export { headHints } from "./server/head-hints.js";
41
41
  export { renderHeadMeta } from "./server/metadata.js";
42
42
  export {
43
43
  clearHtmlCache,
44
+ dropHtmlCacheKey,
44
45
  getHtmlCacheEntries,
45
46
  getHtmlCacheSize,
46
47
  invalidateHtmlCache,
@@ -49,13 +50,32 @@ export {
49
50
  export {
50
51
  clearDataCache,
51
52
  dataCache,
53
+ dropDataCacheKey,
52
54
  getDataCacheEntries,
53
55
  getDataCacheSize,
56
+ getDataCacheStats,
54
57
  withDataCache,
55
58
  } from "./server/data-cache.js";
56
59
  // Paylaşımlı önbellek kademesinin durumu. Bağlantı kurulmamışken de güvenle
57
60
  // çağrılır; healthcheck uçları bunu okuyor.
58
- export { getRedisStatus } from "./server/redis.js";
61
+ // `getRedisDetails()` bağlantının nereye kurulduğunu ve hangi türleri
62
+ // paylaştığını söyler (şifre asla dönmez); `inspectRedis()` tür başına anahtar
63
+ // sayar — bir `SCAN` turu olduğu için istek yolunda çağrılmamalı.
64
+ export { getRedisDetails, getRedisStatus, inspectRedis } from "./server/redis.js";
65
+ // CDN kademesi. JSkelet'in önbelleği origin önbelleği; ziyaretçinin gördüğü
66
+ // kopya edge'de duruyor. Bir içeriği gerçekten tazelemek için ikisini birlikte
67
+ // düşürmek gerekiyor: `invalidateHtmlCache()` + `purgeCloudflare()`.
68
+ export {
69
+ cloudflareConfigured,
70
+ fetchCacheAnalytics,
71
+ fetchCloudflareOverview,
72
+ fetchPathEdges,
73
+ getCloudflareStatus,
74
+ purgeCloudflare,
75
+ toCloudflareUrls,
76
+ } from "./server/cloudflare.js";
77
+ // Upstream hız freninin host başına durumu: healthcheck ve teşhis uçları için.
78
+ export { getUpstreamLimiterStatus } from "./server/upstream-limiter.js";
59
79
  export { prewarm, prewarmProgress } from "./server/prewarm.js";
60
80
  export { createProxy } from "./server/middleware/upstream-proxy.js";
61
81
  export { getConfig, loadConfig } from "./config/index.js";
package/src/log.mjs CHANGED
@@ -284,6 +284,31 @@ function truncate(text, max) {
284
284
  /** Kutunun içi sabit genişlikte; uzun satırlar kırpılır. */
285
285
  const BOX = 52;
286
286
 
287
+ /**
288
+ * Çerçeveli bilgi kutusu.
289
+ *
290
+ * Açılış logunda kaybolmaması gereken tek şey sır: önbellek panelinin şifresi
291
+ * her restart'ta değişiyor ve kullanıcı onu bir kez, akışın içinde görüyor.
292
+ * Genişlik içeriğe göre büyür — bir URL'i ya da 32 haneli bir şifreyi
293
+ * kırpmak kutunun bütün amacını bozar.
294
+ *
295
+ * @param {{ title: string, lines: string[],
296
+ * tint?: (value: string) => string }} info `lines` içinde boş string ayırıcı
297
+ * satır olur. Satırlar **düz metin** olmalı: bir ANSI dizisi `length`e
298
+ * sayıldığı için hizalamayı bozar, rengi çerçeve taşır.
299
+ */
300
+ export function box({ title, lines, tint = c.cyan }) {
301
+ const width = Math.max(BOX, title.length + 4, ...lines.map((text) => text.length + 4));
302
+ const top = `┌─ ${title} ${"─".repeat(width - title.length - 3)}┐`;
303
+ const bottom = `└${"─".repeat(width)}┘`;
304
+
305
+ write(`\n${tint(top)}\n`);
306
+ for (const text of ["", ...lines, ""]) {
307
+ write(`${tint("│")} ${text}${" ".repeat(width - 4 - text.length)} ${tint("│")}\n`);
308
+ }
309
+ write(`${tint(bottom)}\n\n`);
310
+ }
311
+
287
312
  /**
288
313
  * Çerçeveli hata kutusu — kendi framework'ünü geliştirirken hatanın
289
314
  * akış içinde kaybolmaması için.