jskelet 0.2.2 → 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.
@@ -120,6 +120,9 @@ async function act(body) {
120
120
  const result = await response.json().catch(() => ({ ok: false, message: "Failed" }));
121
121
  toast(result.message ?? (result.ok ? "Done" : "Failed"), result.ok !== false);
122
122
  await load();
123
+
124
+ // Cloudflare ayarları değişmiş olabilir; genel bakış önbelleği atlanır.
125
+ if (String(body.type ?? "").startsWith("cf:")) await loadCloudflare(true);
123
126
  }
124
127
 
125
128
  /* ------------------------------------------------------------------- çizim */
@@ -127,7 +130,17 @@ async function act(body) {
127
130
  function render() {
128
131
  if (!latest) return;
129
132
 
130
- const { process: proc, html, data, redis, prewarm } = latest;
133
+ const { process: proc, release, host, html, data, redis, prewarm } = latest;
134
+
135
+ $("version").textContent = `v${release.version}`;
136
+ $("foot-release").textContent = [
137
+ `v${release.version}`,
138
+ release.license,
139
+ release.node ? `Node ${release.node}` : null,
140
+ ]
141
+ .filter(Boolean)
142
+ .join(" · ");
143
+ if (release.homepage) $("foot-repo").href = release.homepage;
131
144
 
132
145
  $("env").textContent = proc.env;
133
146
  $("pid").textContent = `pid ${proc.pid}`;
@@ -152,9 +165,12 @@ function render() {
152
165
 
153
166
  $("redis-state").textContent = redisState;
154
167
  $("redis-sub").textContent = redis.enabled
155
- ? `${redis.errors} errors · ${redis.keyPrefix || "no prefix"}`
168
+ ? `${redis.address} · ${redis.errors} errors`
156
169
  : "cache.redis.enabled is false";
157
170
 
171
+ renderRedis(redis, redisState);
172
+ renderHost(host, proc, redis);
173
+
158
174
  const done = prewarm.done ?? 0;
159
175
  const total = prewarm.total ?? 0;
160
176
  $("prewarm-state").textContent = prewarm.active ? `${done}/${total}` : "idle";
@@ -167,6 +183,338 @@ function render() {
167
183
  renderTable();
168
184
  }
169
185
 
186
+ /* ------------------------------------------------------------- cloudflare */
187
+
188
+ /** @type {any} */
189
+ let cloudflare = null;
190
+
191
+ /**
192
+ * Cloudflare genel bakışı ayrı bir uçtan gelir: zone bilgisi ve ayarlar ağa
193
+ * çıkıyor, döküm turu ise saniyeler mertebesinde yenileniyor. Bu yüzden
194
+ * yalnızca açılışta ve bir işlemden sonra istenir.
195
+ *
196
+ * @param {boolean} [force]
197
+ */
198
+ async function loadCloudflare(force = false) {
199
+ const response = await fetch(`cloudflare${force ? "?force=1" : ""}`);
200
+ if (!alive(response)) return;
201
+
202
+ cloudflare = await response.json().catch(() => null);
203
+ renderCloudflare();
204
+ }
205
+
206
+ function renderCloudflare() {
207
+ if (!cloudflare) return;
208
+
209
+ const ok = cloudflare.ok === true;
210
+ const status = cloudflare.status ?? {};
211
+ const settings = cloudflare.settings ?? {};
212
+ const zone = cloudflare.zone ?? null;
213
+
214
+ $("cf-heading").textContent = ok
215
+ ? [zone?.name, zone?.plan].filter(Boolean).join(" · ") || "connected"
216
+ : status.configured
217
+ ? `error: ${cloudflare.error}`
218
+ : "not connected";
219
+
220
+ $("cf-tools").hidden = !ok;
221
+ $("cf-suggest").hidden = ok || status.configured;
222
+ $("cf-refresh").hidden = !status.configured;
223
+
224
+ if (!ok) {
225
+ $("cf-kv").replaceChildren();
226
+ return;
227
+ }
228
+
229
+ const development = settings.developmentMode === "on";
230
+ $("cf-dev").checked = development;
231
+
232
+ /** @type {[string, string, string?][]} */
233
+ const rows = [
234
+ ["Zone", `${zone?.name ?? "—"} (${status.zoneId ?? "?"})`],
235
+ ["Plan", zone?.plan ?? "—"],
236
+ ["Token", status.tokenSource === "env" ? "from environment" : "from config", "on"],
237
+ [
238
+ "Development mode",
239
+ development
240
+ ? `on · ${formatDuration(settings.developmentModeRemaining)} left`
241
+ : "off",
242
+ development ? "bad" : "on",
243
+ ],
244
+ ["Cache level", String(settings.cacheLevel ?? "—")],
245
+ ["Browser cache TTL", ttl(settings.browserCacheTtl)],
246
+ ["Edge cache TTL", ttl(settings.edgeCacheTtl)],
247
+ ["Query string sorting", String(settings.sortQueryString ?? "—")],
248
+ ["Always Online", String(settings.alwaysOnline ?? "—")],
249
+ ...feature("Tiered Cache", settings.tieredCaching),
250
+ ...feature("Regional Tiered Cache", settings.regionalTieredCache),
251
+ ...feature("Cache Reserve", settings.cacheReserve),
252
+ ];
253
+
254
+ $("cf-kv").replaceChildren(...rows.map(kvRow));
255
+ }
256
+
257
+ /**
258
+ * Plan dışı kalan bir özellik `null` döner. "off" yazmak yanıltıcı olurdu:
259
+ * kapatılmış değil, o planda hiç yok.
260
+ *
261
+ * @param {string} label
262
+ * @param {string | null} value
263
+ * @returns {[string, string, string?][]}
264
+ */
265
+ function feature(label, value) {
266
+ if (value === null || value === undefined) {
267
+ return [[label, "unavailable on this plan", "off"]];
268
+ }
269
+ return [[label, value, value === "on" ? "on" : "off"]];
270
+ }
271
+
272
+ /**
273
+ * @param {unknown} seconds
274
+ * @returns {string}
275
+ */
276
+ function ttl(seconds) {
277
+ const value = Number(seconds);
278
+ if (!Number.isFinite(value)) return "—";
279
+ // Cloudflare 0'ı "Respect Existing Headers" olarak kullanıyor.
280
+ if (value === 0) return "respect origin headers";
281
+ return formatDuration(value);
282
+ }
283
+
284
+ /**
285
+ * @param {[string, string, string?]} row
286
+ * @returns {HTMLDivElement}
287
+ */
288
+ function kvRow([label, value, tone]) {
289
+ const wrap = document.createElement("div");
290
+ const dt = document.createElement("dt");
291
+ dt.textContent = label;
292
+ const dd = document.createElement("dd");
293
+ if (tone) dd.className = tone;
294
+ dd.textContent = value;
295
+ wrap.append(dt, dd);
296
+ return wrap;
297
+ }
298
+
299
+ /**
300
+ * Analitik dökümü. İki soru tek uçtan cevaplanıyor: yol verilirse kolo
301
+ * kırılımı, verilmezse zone genelinde cache durumu dağılımı.
302
+ */
303
+ async function loadCloudflareAnalytics() {
304
+ const path = $("cf-path").value.trim();
305
+ const target = $("cf-report");
306
+
307
+ target.hidden = false;
308
+ target.textContent = "Querying Cloudflare…";
309
+
310
+ const response = await fetch("cloudflare/analytics", {
311
+ method: "POST",
312
+ headers: {
313
+ "Content-Type": "application/json",
314
+ "X-JSkelet-Cache-Panel": "1",
315
+ },
316
+ body: JSON.stringify({ path }),
317
+ });
318
+
319
+ if (!alive(response)) return;
320
+
321
+ const result = await response.json().catch(() => null);
322
+
323
+ if (!result?.ok) {
324
+ target.textContent = `Cloudflare: ${result?.error ?? "query failed"}`;
325
+ return;
326
+ }
327
+
328
+ target.replaceChildren(
329
+ result.colos ? edgeTable(result) : statusTable(result),
330
+ note(
331
+ result.colos
332
+ ? `${result.colos.length} colos served \`${result.path}\` in the last ` +
333
+ `${result.hours}h — ${result.hits} from cache, ${result.misses} not. ` +
334
+ "Edges that received no request do not appear, even if they hold a copy."
335
+ : `Zone-wide cache status over the last ${result.hours}h. ` +
336
+ "Numbers come from Cloudflare's sampled dataset: ratios are reliable, " +
337
+ "absolute counts are estimates.",
338
+ ),
339
+ );
340
+ }
341
+
342
+ /**
343
+ * @param {any} result
344
+ * @returns {HTMLTableElement}
345
+ */
346
+ function edgeTable(result) {
347
+ return buildTable(
348
+ ["Colo", "From cache", "To origin"],
349
+ result.colos.map((/** @type {any} */ row) => [
350
+ row.colo,
351
+ String(row.hits),
352
+ String(row.misses),
353
+ ]),
354
+ );
355
+ }
356
+
357
+ /**
358
+ * @param {any} result
359
+ * @returns {HTMLTableElement}
360
+ */
361
+ function statusTable(result) {
362
+ const total = result.rows.reduce((sum, /** @type {any} */ row) => sum + row.requests, 0) || 1;
363
+
364
+ return buildTable(
365
+ ["Cache status", "Requests", "Share", "Edge bytes"],
366
+ result.rows.map((/** @type {any} */ row) => [
367
+ row.status,
368
+ row.requests.toLocaleString(),
369
+ `${Math.round((row.requests / total) * 100)}%`,
370
+ formatBytes(row.bytes),
371
+ ]),
372
+ );
373
+ }
374
+
375
+ /**
376
+ * @param {string[]} headers
377
+ * @param {string[][]} rows
378
+ * @returns {HTMLTableElement}
379
+ */
380
+ function buildTable(headers, rows) {
381
+ const table = document.createElement("table");
382
+ const thead = document.createElement("thead");
383
+ const headRow = document.createElement("tr");
384
+
385
+ headers.forEach((label, index) => {
386
+ const th = document.createElement("th");
387
+ th.textContent = label;
388
+ if (index > 0) th.className = "num";
389
+ headRow.append(th);
390
+ });
391
+
392
+ thead.append(headRow);
393
+
394
+ const tbody = document.createElement("tbody");
395
+ for (const row of rows) {
396
+ const tr = document.createElement("tr");
397
+ row.forEach((value, index) => tr.append(cell(value, index > 0 ? "num" : undefined)));
398
+ tbody.append(tr);
399
+ }
400
+
401
+ table.append(thead, tbody);
402
+ return table;
403
+ }
404
+
405
+ /**
406
+ * @param {string} text
407
+ * @returns {HTMLParagraphElement}
408
+ */
409
+ function note(text) {
410
+ const paragraph = document.createElement("p");
411
+ paragraph.className = "note m0";
412
+ paragraph.textContent = text;
413
+ return paragraph;
414
+ }
415
+
416
+ /**
417
+ * Paylaşımlı kademenin künyesi.
418
+ *
419
+ * "Bağlı mı" tek başına yetmiyor: yanlış `namespace` ya da beklenmeyen bir
420
+ * `buildId` ile bağlı bir Redis de hiçbir şey paylaşmıyor gibi görünür. Bu
421
+ * yüzden anahtarın **nerede** durduğu ve hangi türlerin paylaşıldığı da
422
+ * yazılır.
423
+ *
424
+ * @param {any} redis
425
+ * @param {string} state
426
+ */
427
+ function renderRedis(redis, state) {
428
+ $("redis-heading").textContent = redis.enabled ? state : "not configured";
429
+ $("redis-actions").hidden = !redis.enabled;
430
+ $("redis-suggest").hidden = redis.enabled;
431
+
432
+ /** @type {[string, string, string?][]} */
433
+ const rows = redis.enabled
434
+ ? [
435
+ ["Connection", state, redis.connected && !redis.bypassed ? "on" : "bad"],
436
+ ["Address", `${redis.address}${redis.secure ? " (TLS)" : ""}`],
437
+ ["Database", redis.db ?? "default"],
438
+ ["Key prefix", `${redis.keyPrefix}:${redis.namespace}`],
439
+ ["Build id", redis.buildId || "dev"],
440
+ ["Shares HTML", redis.html ? "yes" : "no", redis.html ? "on" : "off"],
441
+ ["Shares data", redis.data ? "yes" : "no", redis.data ? "on" : "off"],
442
+ [
443
+ "Compressed bodies",
444
+ redis.storeEncoded ? "shared" : "local only",
445
+ redis.storeEncoded ? "on" : "off",
446
+ ],
447
+ [
448
+ "Purge broadcast",
449
+ redis.events ? (redis.subscribed ? "subscribed" : "publish only") : "off",
450
+ redis.events && redis.subscribed ? "on" : "off",
451
+ ],
452
+ ["Command timeout", `${redis.commandTimeoutMs} ms`],
453
+ ["Command errors", String(redis.errors), redis.errors ? "bad" : "on"],
454
+ ]
455
+ : [
456
+ ["Tier", "in-process only"],
457
+ ["Replicas sharing this cache", "none"],
458
+ ["Purge broadcast", "local only", "off"],
459
+ ];
460
+
461
+ $("redis-kv").replaceChildren(...rows.map(kvRow));
462
+ }
463
+
464
+ /**
465
+ * Makinenin RAM ve disk durumu. Paylaşımlı kademe kapalıyken önbelleğin
466
+ * tamamı bu sürecin belleğinde yaşıyor; `maxEntries` ile RAM arasındaki
467
+ * ilişkiyi görmeden ayar yapmak körlemesine oluyor.
468
+ *
469
+ * @param {any} host
470
+ * @param {any} proc
471
+ * @param {any} redis
472
+ */
473
+ function renderHost(host, proc, redis) {
474
+ $("host-heading").textContent = [
475
+ host.platform,
476
+ `${host.cpus} cpu`,
477
+ host.load ? `load ${host.load.toFixed(2)}` : null,
478
+ ]
479
+ .filter(Boolean)
480
+ .join(" · ");
481
+
482
+ const ramUsed = host.memory.used / host.memory.total;
483
+ $("ram-text").textContent =
484
+ `${formatBytes(host.memory.used)} / ${formatBytes(host.memory.total)}`;
485
+ meter("ram-bar", ramUsed);
486
+ $("ram-note").textContent = redis.connected
487
+ ? `This process: ${formatBytes(proc.memory.rss)} RSS.`
488
+ : `This process holds the whole cache: ${formatBytes(proc.memory.rss)} RSS, ` +
489
+ `${formatBytes(latest.html.bytes)} of it HTML.`;
490
+
491
+ if (!host.disk) {
492
+ $("disk-text").textContent = "unavailable";
493
+ meter("disk-bar", 0);
494
+ $("disk-note").textContent = "This platform does not report filesystem stats.";
495
+ return;
496
+ }
497
+
498
+ const diskUsed = (host.disk.total - host.disk.free) / host.disk.total;
499
+ $("disk-text").textContent =
500
+ `${formatBytes(host.disk.total - host.disk.free)} / ${formatBytes(host.disk.total)}`;
501
+ meter("disk-bar", diskUsed);
502
+ // Önbellek diske yazılmıyor; disk build çıktısı ve log için önemli.
503
+ $("disk-note").textContent =
504
+ `${formatBytes(host.disk.free)} free on ${host.disk.path} — build output and logs live here.`;
505
+ }
506
+
507
+ /**
508
+ * @param {string} id
509
+ * @param {number} ratio 0–1
510
+ */
511
+ function meter(id, ratio) {
512
+ const bar = $(id);
513
+ const percent = Math.max(0, Math.min(100, ratio * 100));
514
+ bar.style.width = `${percent}%`;
515
+ bar.className = percent >= 90 ? "bad" : percent >= 75 ? "warn" : "";
516
+ }
517
+
170
518
  function renderTable() {
171
519
  const source = tab === "html" ? latest.html : latest.data;
172
520
  const thead = $("thead");
@@ -198,7 +546,29 @@ function row(entry) {
198
546
 
199
547
  const key = document.createElement("td");
200
548
  key.className = "key mono";
201
- key.textContent = entry.key;
549
+
550
+ if (tab === "html") {
551
+ // HTML anahtarı gezilebilir bir yol: yeni sekmede açmak, bir sayfanın
552
+ // neden bayat olduğunu anlamanın en kısa yolu. `noreferrer` panelin
553
+ // yolunu `Referer` başlığıyla sızdırmamak için.
554
+ const link = document.createElement("a");
555
+ link.href = entry.url;
556
+ link.target = "_blank";
557
+ link.rel = "noreferrer noopener";
558
+ link.textContent = entry.url;
559
+ key.append(link);
560
+ } else {
561
+ // Veri anahtarı bir URL değil; tıklamanın işe yarar karşılığı onu
562
+ // `clearDataCache()` çağrısına yapıştırabilmek.
563
+ const copy = document.createElement("button");
564
+ copy.className = "as-text";
565
+ copy.type = "button";
566
+ copy.title = "Copy key";
567
+ copy.textContent = entry.key;
568
+ copy.addEventListener("click", () => void copyKey(entry.key));
569
+ key.append(copy);
570
+ }
571
+
202
572
  tr.append(key);
203
573
 
204
574
  const state = document.createElement("td");
@@ -223,6 +593,19 @@ function row(entry) {
223
593
  const actions = document.createElement("td");
224
594
  actions.className = "actions";
225
595
 
596
+ // Tek satırı hem origin'den hem edge'den düşürmek en sık istenen çift
597
+ // işlem; Cloudflare bağlı değilse düğme hiç çıkmaz.
598
+ if (tab === "html" && cloudflare?.ok) {
599
+ const purge = document.createElement("button");
600
+ purge.className = "tiny";
601
+ purge.textContent = "cf purge";
602
+ purge.title = "Purge this URL at Cloudflare";
603
+ purge.addEventListener("click", () => {
604
+ void act({ type: "cf:purge-urls", paths: [entry.url] });
605
+ });
606
+ actions.append(purge);
607
+ }
608
+
226
609
  const drop = document.createElement("button");
227
610
  drop.className = "tiny danger";
228
611
  drop.textContent = "drop";
@@ -236,6 +619,23 @@ function row(entry) {
236
619
  return tr;
237
620
  }
238
621
 
622
+ /**
623
+ * Panel `http://localhost` dışında da açılabiliyor ve tarayıcı güvensiz
624
+ * kaynaklarda pano API'sini vermiyor; o durumda anahtar seçili bırakılır.
625
+ *
626
+ * @param {string} key
627
+ */
628
+ async function copyKey(key) {
629
+ try {
630
+ await navigator.clipboard.writeText(key);
631
+ toast("Key copied");
632
+ } catch {
633
+ $("prefix").value = key;
634
+ $("prefix").focus();
635
+ toast("Clipboard is unavailable — key moved to the prefix field", false);
636
+ }
637
+ }
638
+
239
639
  /**
240
640
  * @param {string} text
241
641
  * @param {string} [className]
@@ -317,6 +717,10 @@ $("clear-data").addEventListener("click", () => {
317
717
 
318
718
  $("prewarm").addEventListener("click", () => void act({ type: "prewarm" }));
319
719
 
720
+ // Sayım ayrı bir düğme: her döküm turunda tüm keyspace'i taramak Redis'i
721
+ // teşhis uğruna yormak olurdu.
722
+ $("redis-inspect").addEventListener("click", () => void act({ type: "redis:inspect" }));
723
+
320
724
  $("redis-html").addEventListener("click", () =>
321
725
  void act({ type: "redis:drop", kind: "html" }),
322
726
  );
@@ -325,6 +729,70 @@ $("redis-data").addEventListener("click", () =>
325
729
  void act({ type: "redis:drop", kind: "data" }),
326
730
  );
327
731
 
732
+ /* ------------------------------------------------- cloudflare kontrolleri */
733
+
734
+ $("cf-refresh").addEventListener("click", () => void loadCloudflare(true));
735
+
736
+ $("cf-purge-all").addEventListener("click", () => {
737
+ if (!confirm("Purge the entire Cloudflare cache for this zone?")) return;
738
+ void act({ type: "cf:purge-everything" });
739
+ });
740
+
741
+ // Bu kurulumda sıcak olan yollar zaten biliniyor; "her şeyi düşür" yerine
742
+ // yalnızca gerçekten servis edilen sayfaları düşürmek edge'i boşa soğutmaz.
743
+ $("cf-purge-cached").addEventListener("click", () => {
744
+ const paths = (latest?.html.entries ?? []).map((/** @type {any} */ entry) => entry.url);
745
+ if (!paths.length) {
746
+ toast("No cached pages to purge.", false);
747
+ return;
748
+ }
749
+
750
+ if (!confirm(`Purge ${paths.length} URLs at Cloudflare?`)) return;
751
+ void act({ type: "cf:purge-urls", paths });
752
+ });
753
+
754
+ $("cf-purge-keys").addEventListener("click", () => {
755
+ const values = $("cf-values").value.trim();
756
+ if (!values) {
757
+ toast("Enter at least one value.", false);
758
+ return;
759
+ }
760
+
761
+ void act({ type: "cf:purge-keys", kind: $("cf-kind").value, values });
762
+ });
763
+
764
+ $("cf-dev").addEventListener("change", (event) => {
765
+ const on = /** @type {HTMLInputElement} */ (event.target).checked;
766
+ void act({ type: "cf:setting", id: "development_mode", value: on ? "on" : "off" });
767
+ });
768
+
769
+ $("cf-tiered").addEventListener("click", () => {
770
+ const current = cloudflare?.settings?.tieredCaching;
771
+ void act({ type: "cf:feature", feature: "tiered_caching", value: flip(current) });
772
+ });
773
+
774
+ $("cf-reserve").addEventListener("click", () => {
775
+ const current = cloudflare?.settings?.cacheReserve;
776
+ void act({ type: "cf:feature", feature: "cache_reserve", value: flip(current) });
777
+ });
778
+
779
+ $("cf-reserve-clear").addEventListener("click", () => {
780
+ if (!confirm("Clear Cache Reserve? Cloudflare will refetch everything from the origin.")) {
781
+ return;
782
+ }
783
+ void act({ type: "cf:clear-reserve" });
784
+ });
785
+
786
+ $("cf-analytics").addEventListener("click", () => void loadCloudflareAnalytics());
787
+
788
+ /**
789
+ * @param {string | null | undefined} value
790
+ * @returns {"on" | "off"}
791
+ */
792
+ function flip(value) {
793
+ return value === "on" ? "off" : "on";
794
+ }
795
+
328
796
  // Sekme arkaya alındığında yoklama durur: panel açık kalmış bir sekmede
329
797
  // dakikalarca boşa istek atmasın.
330
798
  document.addEventListener("visibilitychange", () => {
@@ -336,4 +804,5 @@ document.addEventListener("visibilitychange", () => {
336
804
  });
337
805
 
338
806
  void load();
807
+ void loadCloudflare();
339
808
  startPolling();
@@ -215,6 +215,37 @@ export const DEFAULT_CACHE_PANEL = {
215
215
  sessionHours: 12,
216
216
  };
217
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
+
218
249
  /** Oturuma bağlı sayfalar ısıtılmaz; uygulama kendi listesini verebilir. */
219
250
  export const DEFAULT_PREWARM_SKIP = [
220
251
  "/api/",