jskelet 0.6.0 → 0.6.1

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.
@@ -37,10 +37,15 @@ import {
37
37
  DEFAULT_AUTH,
38
38
  DEFAULT_BRAND,
39
39
  DEFAULT_CLOUDFLARE,
40
+ DATA_CACHE_MAX_ENTRIES_CEILING,
40
41
  DEFAULT_DATA_CACHE,
41
42
  DEFAULT_DEV_GATE_BYPASS,
42
43
  DEFAULT_DIRS,
43
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,
44
49
  DEFAULT_IMAGES,
45
50
  DEFAULT_LOGS,
46
51
  DEFAULT_NAVIGATION,
@@ -134,6 +139,8 @@ const CONFIG_FILE = "jskelet.config.mjs";
134
139
  * @property {string[] | null} routes Açık route modülü listesi.
135
140
  * @property {boolean} trailingSlash URL'ler `/` ile bitsin mi (Next `trailingSlash`).
136
141
  * @property {{ extensions: Set<string>, prefixes: string[] }} static
142
+ * @property {boolean} devGate `DEV_TOKEN` tek başına siteyi kilitlemez; gate
143
+ * ancak bu bayrak veya `DEV_GATE=1` ile açılır.
137
144
  * @property {string[]} devGateBypass
138
145
  * @property {string[]} preconnect
139
146
  * @property {NavigationConfig} navigation
@@ -348,6 +355,22 @@ function normalizeUpstream(raw) {
348
355
  };
349
356
  }
350
357
 
358
+ /**
359
+ * Dev gate. `DEV_TOKEN` ortamda durması siteyi kilitlemez: paylaşılan bir
360
+ * task tanımı production'a da aynı değişkeni taşır ve herkese 404 olur.
361
+ * Gate ancak `devGate: true` ya da `DEV_GATE=1` ile açılır. `DEV_GATE=0`
362
+ * config'teki açığı da kapatır.
363
+ *
364
+ * @param {Record<string, any>} source
365
+ * @returns {boolean}
366
+ */
367
+ function normalizeDevGate(source) {
368
+ const env = process.env.DEV_GATE;
369
+ if (env === "0" || env === "false") return false;
370
+ if (env === "1" || env === "true") return true;
371
+ return source.devGate === true;
372
+ }
373
+
351
374
  /**
352
375
  * Yönetim paneli. `enabled` yalnızca açıkça `true` verildiğinde ya da
353
376
  * `JSKELET_ADMIN` ortam değişkeni ayarlandığında açılır: paneli yanlışlıkla
@@ -701,16 +724,24 @@ function normalizeCache(raw) {
701
724
  const prewarm = normalizePrewarm(raw?.prewarm);
702
725
  const queryRules = normalizeQueryRules(raw?.query);
703
726
  const maxEntries = Number(raw?.maxEntries);
727
+ let htmlMaxEntries =
728
+ Number.isFinite(maxEntries) && maxEntries > 0
729
+ ? Math.floor(maxEntries)
730
+ : DEFAULT_HTML_CACHE_MAX_ENTRIES;
731
+ if (htmlMaxEntries > HTML_CACHE_MAX_ENTRIES_CEILING) {
732
+ console.warn(
733
+ `[config] cache().maxEntries ${htmlMaxEntries} exceeds the ceiling of ` +
734
+ `${HTML_CACHE_MAX_ENTRIES_CEILING}; using ${HTML_CACHE_MAX_ENTRIES_CEILING}`,
735
+ );
736
+ htmlMaxEntries = HTML_CACHE_MAX_ENTRIES_CEILING;
737
+ }
704
738
 
705
739
  return {
706
740
  html,
707
741
  cacheQuery: queryRules,
708
742
  cacheVary: normalizeVary(raw?.vary),
709
- htmlMaxEntries:
710
- Number.isFinite(maxEntries) && maxEntries > 0
711
- ? Math.floor(maxEntries)
712
- : DEFAULT_HTML_CACHE_MAX_ENTRIES,
713
- data: { ...DEFAULT_DATA_CACHE, ...(raw?.data ?? {}) },
743
+ htmlMaxEntries,
744
+ data: normalizeDataCache(raw?.data),
714
745
  // Otomatik upstream izleme kapatılabilir olmalı: `fetch`i kendisi saran
715
746
  // bir uygulama (ölçüm, retry, circuit breaker) çakışma yaşayabilir.
716
747
  trackUpstream: raw?.trackUpstream !== false,
@@ -732,14 +763,110 @@ function normalizeCache(raw) {
732
763
  };
733
764
  }
734
765
 
766
+ /**
767
+ * İstenen pozitif tamsayı tavanı aşıyorsa uyarı basıp tavana çeker.
768
+ *
769
+ * @param {string} field
770
+ * @param {number} requested
771
+ * @param {number} ceiling
772
+ * @returns {number}
773
+ */
774
+ function clampCeiling(field, requested, ceiling) {
775
+ if (requested <= ceiling) return requested;
776
+ console.warn(
777
+ `[config] ${field} ${requested} exceeds the ceiling of ${ceiling}; using ${ceiling}`,
778
+ );
779
+ return ceiling;
780
+ }
781
+
782
+ /**
783
+ * Veri önbelleği JSON tutar; sınır HTML'den yüksek olabilir ama sonsuz değil.
784
+ *
785
+ * @param {unknown} raw
786
+ * @returns {{ maxEntries: number, staleFactor: number }}
787
+ */
788
+ function normalizeDataCache(raw) {
789
+ const source =
790
+ raw && typeof raw === "object" && !Array.isArray(raw)
791
+ ? /** @type {Record<string, unknown>} */ (raw)
792
+ : {};
793
+ const requested = Number(source.maxEntries);
794
+ let maxEntries =
795
+ Number.isFinite(requested) && requested > 0
796
+ ? Math.floor(requested)
797
+ : DEFAULT_DATA_CACHE.maxEntries;
798
+ maxEntries = clampCeiling(
799
+ "cache().data.maxEntries",
800
+ maxEntries,
801
+ DATA_CACHE_MAX_ENTRIES_CEILING,
802
+ );
803
+
804
+ const stale = Number(source.staleFactor);
805
+ return {
806
+ maxEntries,
807
+ staleFactor:
808
+ Number.isFinite(stale) && stale >= 0 ? stale : DEFAULT_DATA_CACHE.staleFactor,
809
+ };
810
+ }
811
+
812
+ /**
813
+ * onVisit sürekli çalışır. Klasik turdaki `rps: 0` (sınırsız) burada her
814
+ * ziyaretçide yeniden crawl demek; boş, `0` ve tavanın üstü 2'ye çekilir.
815
+ *
816
+ * @param {Record<string, unknown>} source
817
+ * @returns {{ enabled: boolean } & typeof DEFAULT_PREWARM_ON_VISIT}
818
+ */
819
+ function resolveOnVisitLimits(source) {
820
+ const perPageRaw = Number(source.perPage);
821
+ const perPage = clampCeiling(
822
+ "cache().prewarm.onVisit.perPage",
823
+ Number.isFinite(perPageRaw) && perPageRaw > 0
824
+ ? Math.floor(perPageRaw)
825
+ : DEFAULT_PREWARM_ON_VISIT.perPage,
826
+ ON_VISIT_PER_PAGE_CEILING,
827
+ );
828
+
829
+ const concurrencyRaw = Number(source.concurrency);
830
+ const concurrency = clampCeiling(
831
+ "cache().prewarm.onVisit.concurrency",
832
+ Number.isFinite(concurrencyRaw) && concurrencyRaw > 0
833
+ ? Math.floor(concurrencyRaw)
834
+ : ON_VISIT_CONCURRENCY_CEILING,
835
+ ON_VISIT_CONCURRENCY_CEILING,
836
+ );
837
+
838
+ const rpsRaw = Number(source.rps);
839
+ let rps = ON_VISIT_RPS_CEILING;
840
+ if (source.rps != null && source.rps !== "") {
841
+ if (!Number.isFinite(rpsRaw) || rpsRaw <= 0) {
842
+ console.warn(
843
+ `[config] cache().prewarm.onVisit.rps ${source.rps} is not a positive rate; ` +
844
+ `using ${ON_VISIT_RPS_CEILING}`,
845
+ );
846
+ } else {
847
+ rps = clampCeiling(
848
+ "cache().prewarm.onVisit.rps",
849
+ rpsRaw,
850
+ ON_VISIT_RPS_CEILING,
851
+ );
852
+ }
853
+ }
854
+
855
+ return {
856
+ ...DEFAULT_PREWARM_ON_VISIT,
857
+ enabled: source.enabled !== false,
858
+ perPage,
859
+ concurrency,
860
+ rps,
861
+ };
862
+ }
863
+
735
864
  /**
736
865
  * @param {unknown} raw
737
866
  * @returns {{ enabled: boolean } & typeof DEFAULT_PREWARM_ON_VISIT}
738
867
  */
739
868
  function normalizeOnVisit(raw) {
740
- if (raw === true) {
741
- return { ...DEFAULT_PREWARM_ON_VISIT, enabled: true };
742
- }
869
+ if (raw === true) return resolveOnVisitLimits({});
743
870
 
744
871
  if (raw == null || raw === false) {
745
872
  return { ...DEFAULT_PREWARM_ON_VISIT, enabled: false };
@@ -751,24 +878,7 @@ function normalizeOnVisit(raw) {
751
878
  );
752
879
  }
753
880
 
754
- const source = /** @type {Record<string, unknown>} */ (raw);
755
- const perPage = Number(source.perPage);
756
- const concurrency = Number(source.concurrency);
757
- const rps = Number(source.rps);
758
-
759
- return {
760
- ...DEFAULT_PREWARM_ON_VISIT,
761
- enabled: source.enabled !== false,
762
- perPage:
763
- Number.isFinite(perPage) && perPage > 0
764
- ? Math.floor(perPage)
765
- : DEFAULT_PREWARM_ON_VISIT.perPage,
766
- concurrency:
767
- Number.isFinite(concurrency) && concurrency > 0
768
- ? Math.floor(concurrency)
769
- : null,
770
- rps: Number.isFinite(rps) && rps >= 0 ? rps : null,
771
- };
881
+ return resolveOnVisitLimits(/** @type {Record<string, unknown>} */ (raw));
772
882
  }
773
883
 
774
884
  /**
@@ -1255,6 +1365,7 @@ export async function loadConfig(options = {}) {
1255
1365
  extensions: new Set(source.static?.extensions ?? DEFAULT_STATIC.extensions),
1256
1366
  prefixes: source.static?.prefixes ?? DEFAULT_STATIC.prefixes,
1257
1367
  },
1368
+ devGate: normalizeDevGate(source),
1258
1369
  devGateBypass: source.devGateBypass ?? DEFAULT_DEV_GATE_BYPASS,
1259
1370
  preconnect: source.preconnect ?? [],
1260
1371
  navigation: normalizeNavigation(source.navigation, brand),
@@ -12,6 +12,9 @@
12
12
  * açık kurallar istenen yolu önce görür.
13
13
  * 3b. access log (açıksa) — tamamlanan yanıtların süresi; admin/prewarm
14
14
  * içeride elenir.
15
+ * 3c. robots.txt — statikten önce, compression'ın içinde: kullanıcının
16
+ * gövdesine framework Disallow bloğu eklenir. Sıkıştırılmış kopyanın
17
+ * (Content-Encoding dolu) üzerine yazılmaz.
15
18
  * 4. staticPrecompressed → express.static — build'de üretilmiş `.br`/`.gz`
16
19
  * kopyalar varsa onlar servis edilir (kalite 11), yoksa istek altındaki
17
20
  * static'e düşer ve middleware anında sıkıştırır (kalite 5).
@@ -32,6 +35,7 @@ import path from "node:path";
32
35
  import process from "node:process";
33
36
  import express from "express";
34
37
  import { compression } from "./middleware/compression.js";
38
+ import { robotsTxtMiddleware } from "./middleware/robots-txt.js";
35
39
  import { headersMiddleware } from "./middleware/headers.js";
36
40
  import { csrf } from "./middleware/csrf.js";
37
41
  import { staticPrecompressed } from "./middleware/static-precompressed.js";
@@ -109,6 +113,11 @@ export async function createApp(options = {}) {
109
113
  app.use(accessLogMiddleware());
110
114
  }
111
115
 
116
+ // Kullanıcının robots.txt'i statik dosya da olabilir, route da. İkisi de
117
+ // bu sarmalayıcıdan geçer. compression'dan sonra durur: ek, sıkıştırılmış
118
+ // baytların değil düz metnin sonuna yazılır.
119
+ app.use(robotsTxtMiddleware());
120
+
112
121
  app.use(staticPrecompressed(config.dirs.public));
113
122
  app.use(
114
123
  express.static(config.dirs.public, {
@@ -31,10 +31,13 @@
31
31
  */
32
32
 
33
33
  import { getConfig } from "../config/index.js";
34
- import { DEFAULT_HTML_CACHE_MAX_ENTRIES } from "../config/defaults.js";
34
+ import {
35
+ DEFAULT_HTML_CACHE_MAX_ENTRIES,
36
+ HTML_CACHE_BYTE_BUDGET,
37
+ } from "../config/defaults.js";
35
38
  import { collectDependencies } from "./cache-deps.js";
36
39
  import { compilePattern, matchPattern } from "../config/pattern.js";
37
- import { pathOfCacheKey } from "./cache-vary.js";
40
+ import { pathOfCacheKey, publicHost } from "./cache-vary.js";
38
41
  import {
39
42
  cacheKey,
40
43
  onCacheEvent,
@@ -118,6 +121,19 @@ const EARLY_SWEEP_INTERVAL_MS = 1000;
118
121
  /** @type {Map<string, HtmlEntry>} */
119
122
  const store = new Map();
120
123
 
124
+ /**
125
+ * `store` içindeki HTML string + sıkıştırılmış gövdelerin toplamı.
126
+ * Her `drop` / `install` bunu günceller; sıkıştırma sonradan eklendiğinde
127
+ * `noteHtmlCacheGrowth` artışı yazar. Tam tarama yapmamak için tutulur.
128
+ */
129
+ let storedBytes = 0;
130
+
131
+ /**
132
+ * Testler tahliyeyi küçük bir bütçeyle doğrular. `null` → üretim tavanı.
133
+ * @type {number | null}
134
+ */
135
+ let byteBudgetOverride = null;
136
+
121
137
  /** @type {Map<string, Promise<{ html: string, status: number }>>} */
122
138
  const inflight = new Map();
123
139
 
@@ -237,8 +253,45 @@ function unlink(key, entry) {
237
253
  }
238
254
 
239
255
  /**
240
- * Store'dan silmenin **tek** yolu. Ters indeks bakımı buraya bağlı olduğu için
241
- * hiçbir yerde doğrudan `store.delete()` çağrılmaz.
256
+ * Ham HTML + sıkıştırılmış gövdeler. Bayt bütçesi girdi sayısından bağımsız
257
+ * bu ağırlığa bakar.
258
+ *
259
+ * @param {HtmlEntry} entry
260
+ * @returns {number}
261
+ */
262
+ function entryBytes(entry) {
263
+ let bytes = Buffer.byteLength(entry.html);
264
+ for (const buffer of entry.encoded.values()) bytes += buffer.length;
265
+ return bytes;
266
+ }
267
+
268
+ /**
269
+ * @returns {number}
270
+ */
271
+ function activeByteBudget() {
272
+ return byteBudgetOverride ?? HTML_CACHE_BYTE_BUDGET;
273
+ }
274
+
275
+ /**
276
+ * Sayı tavanı veya bayt bütçesi aşılınca en eski girdiden düşer. Tek başına
277
+ * bütçeyi aşan girdi de gider: yanıt o istekte zaten üretilmiştir, L1'de
278
+ * durması süreci şişirir.
279
+ */
280
+ function evictOverflow() {
281
+ const limit = maxEntries();
282
+ const budget = activeByteBudget();
283
+
284
+ while (store.size > 0 && (store.size > limit || storedBytes > budget)) {
285
+ const oldest = store.keys().next().value;
286
+ if (oldest === undefined) break;
287
+ if (!drop(oldest)) break;
288
+ }
289
+ }
290
+
291
+ /**
292
+ * Store'dan silmenin **tek** yolu. Ters indeks ve bayt sayacı buraya bağlı;
293
+ * hiçbir yerde doğrudan `store.delete()` çağrılmaz. `read()` içindeki
294
+ * sil-yaz yalnızca LRU sırası içindir, sayacı değiştirmez.
242
295
  *
243
296
  * @param {string} key
244
297
  * @returns {boolean} Girdi var mıydı.
@@ -248,6 +301,7 @@ function drop(key) {
248
301
  if (!entry) return false;
249
302
 
250
303
  unlink(key, entry);
304
+ storedBytes = Math.max(0, storedBytes - entryBytes(entry));
251
305
  store.delete(key);
252
306
  return true;
253
307
  }
@@ -406,8 +460,9 @@ function write(key, value, ttlSeconds, deps = null, produceMs = DEFAULT_PRODUCE_
406
460
  produceMs: normalizeProduceMs(produceMs),
407
461
  };
408
462
 
409
- install(key, entry);
410
- share(key, entry);
463
+ // Bütçeye sığmayan sayfa Redis'e de yazılmaz: bir sonraki istek onu
464
+ // geri alıp yine reddeder, stringify ise o anki RSS'i şişirir.
465
+ if (install(key, entry)) share(key, entry);
411
466
  }
412
467
 
413
468
  /**
@@ -416,13 +471,20 @@ function write(key, value, ttlSeconds, deps = null, produceMs = DEFAULT_PRODUCE_
416
471
  *
417
472
  * @param {string} key
418
473
  * @param {HtmlEntry} entry
474
+ * @returns {boolean} Girdi L1'e girdi mi.
419
475
  */
420
476
  function install(key, entry) {
421
477
  // Aynı anahtarın eski girdisi ters indekste kalmasın: bağımlılıklar
422
478
  // tazelemeden tazelemeye değişebilir.
423
479
  drop(key);
424
480
 
481
+ const weight = entryBytes(entry);
482
+ // Tek sayfa bütçeden büyükse saklama. Eski kopya da düştü; bu yanıt
483
+ // üreticinin döndürdüğü HTML ile gider, L1'e girmez.
484
+ if (weight > activeByteBudget()) return false;
485
+
425
486
  store.set(key, entry);
487
+ storedBytes += weight;
426
488
 
427
489
  for (const dep of entry.deps) {
428
490
  let set = dependents.get(dep);
@@ -430,12 +492,35 @@ function install(key, entry) {
430
492
  set.add(key);
431
493
  }
432
494
 
433
- const limit = maxEntries();
434
- while (store.size > limit) {
435
- const oldest = store.keys().next().value;
436
- if (oldest === undefined) break;
437
- drop(oldest);
438
- }
495
+ evictOverflow();
496
+ return store.has(key);
497
+ }
498
+
499
+ /**
500
+ * Sıkıştırılmış gövde `install()`'dan sonra, ilk brotli/gzip yanıtında
501
+ * girdinin `encoded` haritasına eklenir. Sayacı delta ile büyütmek, o sıra
502
+ * LRU'dan düşmüş bir haritaya yazınca bir daha inmeyen bir artık bırakır;
503
+ * store'dan yeniden okumak o artığı taşımaz.
504
+ *
505
+ * @returns {void}
506
+ */
507
+ export function noteHtmlCacheGrowth() {
508
+ let bytes = 0;
509
+ for (const entry of store.values()) bytes += entryBytes(entry);
510
+ storedBytes = bytes;
511
+ evictOverflow();
512
+ }
513
+
514
+ /**
515
+ * Bellek freninin bayt tavanını geçici olarak değiştirir. Testler LRU
516
+ * tahliyesini küçük bir değerle doğrular; `null` üretim tavanına döner.
517
+ *
518
+ * @param {number | null} bytes
519
+ * @returns {void}
520
+ */
521
+ export function setHtmlCacheByteBudget(bytes) {
522
+ byteBudgetOverride = bytes == null ? null : bytes;
523
+ evictOverflow();
439
524
  }
440
525
 
441
526
  /**
@@ -589,6 +674,7 @@ export function clearHtmlCache() {
589
674
  */
590
675
  function clearLocal() {
591
676
  store.clear();
677
+ storedBytes = 0;
592
678
  dependents.clear();
593
679
  tokens.clear();
594
680
  invalidated.clear();
@@ -877,27 +963,65 @@ function dropLocalKey(key) {
877
963
  }
878
964
 
879
965
  /**
880
- * Invalidate edilmiş ve henüz kimsenin istemediği yolları döner ve kuyruğu
881
- * boşaltır. Isıtma turu bunları başa alır; iki tur aynı yolu tekrar
882
- * ısıtmasın diye okuma yıkıcıdır.
966
+ * Vary önekinden `h=` parçasını okur. Önek yoksa boş string.
883
967
  *
884
- * Vary öneki (`h=…|`) düşülür — HTTP ısıtması yalnızca yolu ister; host
885
- * ayrımı `prewarm.origins` / istek Host'u ile yapılır.
968
+ * @param {string} key
969
+ * @returns {string}
970
+ */
971
+ function hostOfCacheKey(key) {
972
+ const sep = key.indexOf("|/");
973
+ if (sep === -1) return "";
974
+ const prefix = key.slice(0, sep);
975
+ for (const part of prefix.split("&")) {
976
+ if (part.startsWith("h=")) return part.slice(2);
977
+ }
978
+ return "";
979
+ }
980
+
981
+ /**
982
+ * @param {string} key
983
+ * @returns {string}
984
+ */
985
+ function pathWithQuery(key) {
986
+ const pathname = pathOfCacheKey(key);
987
+ const q = key.indexOf("?");
988
+ if (q === -1) return pathname;
989
+ const query = key.slice(q + 1);
990
+ return query ? `${pathname}?${query}` : pathname;
991
+ }
992
+
993
+ /**
994
+ * Invalidate edilmiş yollar. Okuma yıkıcıdır; iki tur aynı yolu tekrar
995
+ * ısıtmasın. `onlyHost` verilirse başka host'ların anahtarları kuyrukta
996
+ * kalır — süre dolumu onları kendi host'uyla ısıtır, `127.0.0.1` anahtarı
997
+ * açılmaz.
886
998
  *
887
- * @returns {string[]}
999
+ * @param {string} [onlyHost]
1000
+ * @returns {{ path: string, host: string }[]}
888
1001
  */
889
- export function takeInvalidatedPaths() {
1002
+ export function takeInvalidatedTargets(onlyHost) {
890
1003
  if (!invalidated.size) return [];
891
1004
 
892
- const paths = [...invalidated];
893
- invalidated.clear();
894
- return paths.map((key) => {
895
- const pathname = pathOfCacheKey(key);
896
- const q = key.indexOf("?");
897
- if (q === -1) return pathname;
898
- const query = key.slice(q + 1);
899
- return query ? `${pathname}?${query}` : pathname;
900
- });
1005
+ /** @type {{ path: string, host: string }[]} */
1006
+ const taken = [];
1007
+
1008
+ for (const key of [...invalidated]) {
1009
+ const host = hostOfCacheKey(key);
1010
+ if (onlyHost && host && host !== onlyHost) continue;
1011
+ invalidated.delete(key);
1012
+ taken.push({ path: pathWithQuery(key), host });
1013
+ }
1014
+
1015
+ return taken;
1016
+ }
1017
+
1018
+ /**
1019
+ * Yol listesi. Vary öneki düşülür; host ayrımı `takeInvalidatedTargets`.
1020
+ *
1021
+ * @returns {string[]}
1022
+ */
1023
+ export function takeInvalidatedPaths() {
1024
+ return takeInvalidatedTargets().map((target) => target.path);
901
1025
  }
902
1026
 
903
1027
  /**
@@ -926,14 +1050,36 @@ export function getHtmlCacheEntries() {
926
1050
  * Yol (query'siz) için taze bir HTML girdisi var mı? Ziyaret ısıtması yalnızca
927
1051
  * soğuk / bayat hedefleri kuyruğa alır; HIT'leri yeniden çekmez.
928
1052
  *
1053
+ * Gerçek anahtar `h=host|/yol?` biçimindedir: düz `store.get(pathname)` hem
1054
+ * vary önekini hem sondaki `?` işaretini kaçırır ve sıcak sayfayı yeniden
1055
+ * ısıtır. `vary.host` açıkken yalnızca bu isteğin host'u sayılır; diğer
1056
+ * locale'in kopyası bu yolu sıcak yapmaz.
1057
+ *
929
1058
  * @param {string} pathname
1059
+ * @param {{ headers?: Record<string, unknown>, get?: (name: string) => string | undefined }} [req]
930
1060
  * @returns {boolean}
931
1061
  */
932
- export function isHtmlCacheFresh(pathname) {
1062
+ export function isHtmlCacheFresh(pathname, req) {
933
1063
  if (typeof pathname !== "string" || !pathname.startsWith("/")) return false;
934
- const entry = store.get(pathname);
935
- if (!entry) return false;
936
- return Date.now() < entry.expiresAt;
1064
+
1065
+ let host = "";
1066
+ try {
1067
+ if (getConfig().cacheVary?.host && req) host = publicHost(req);
1068
+ } catch {
1069
+ // Config yokken (testler) önek aranmaz; yol eşleşmesi yeter.
1070
+ }
1071
+
1072
+ const now = Date.now();
1073
+ for (const [key, entry] of store) {
1074
+ if (now >= entry.expiresAt) continue;
1075
+ if (pathOfCacheKey(key) !== pathname) continue;
1076
+ const q = key.indexOf("?");
1077
+ if (q !== -1 && key.slice(q + 1)) continue;
1078
+ if (host && hostOfCacheKey(key) !== host) continue;
1079
+ return true;
1080
+ }
1081
+
1082
+ return false;
937
1083
  }
938
1084
 
939
1085
  /**
@@ -1,11 +1,12 @@
1
1
  /**
2
- * Yayına açılmamış bir ortamı gizler: `DEV_TOKEN` ayarlıyken token taşımayan
3
- * her isteğe 404 döner. 403 değil 404 — 403 ortamın var olduğunu doğrular,
4
- * 404 hiç yokmuş gibi davranır.
2
+ * Yayına açılmamış bir ortamı gizler. Gate kapalıyken (varsayılan) `DEV_TOKEN`
3
+ * olsa bile istekler geçer: değişkenin task ortamında unutulması production'u
4
+ * kilitlemesin. Açmak için `devGate: true` ya da `DEV_GATE=1` gerekir; o zaman
5
+ * token taşımayan her isteğe 404 döner. 403 değil 404 — 403 ortamın var
6
+ * olduğunu doğrular, 404 hiç yokmuş gibi davranır.
5
7
  *
6
8
  * Token bir kez `?dev_token=…` ile gelirse çereze yazılır, böylece link
7
- * paylaşımı yeterli olur. `DEV_TOKEN` yoksa middleware tamamen devre dışıdır
8
- * ve üretimde hiçbir maliyeti olmaz.
9
+ * paylaşımı yeterli olur. Token boşsa gate açık olsa da kimse kilitlenmez.
9
10
  */
10
11
  import process from "node:process";
11
12
  import { getConfig } from "../../config/index.js";
@@ -32,13 +33,25 @@ function readCookie(req, name) {
32
33
 
33
34
  /** @returns {import('express').RequestHandler} */
34
35
  export function devGate() {
35
- const { devGateBypass, brand } = getConfig();
36
+ const { devGate: enabled, devGateBypass, brand } = getConfig();
36
37
  const bypass = new Set(devGateBypass);
37
38
  const cookieName = brand.devTokenCookie;
39
+ const devToken = process.env.DEV_TOKEN;
40
+
41
+ // Unutulmuş bir değişken sessizce herkese 404 kesmesin; operatör görsün.
42
+ if (!enabled && devToken) {
43
+ console.warn(
44
+ "[dev-gate] DEV_TOKEN is set but the gate is off, so the site stays public. " +
45
+ "Set devGate: true or DEV_GATE=1 to require the token.",
46
+ );
47
+ } else if (enabled && !devToken) {
48
+ console.warn(
49
+ "[dev-gate] the gate is on but DEV_TOKEN is empty; requests are not blocked.",
50
+ );
51
+ }
38
52
 
39
53
  return (req, res, next) => {
40
- const devToken = process.env.DEV_TOKEN;
41
- if (!devToken) return next();
54
+ if (!enabled || !devToken) return next();
42
55
  if (bypass.has(req.path)) return next();
43
56
 
44
57
  const fromQuery = req.query?.[cookieName];