jskelet 0.5.2 → 0.5.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/index.js CHANGED
@@ -35,6 +35,13 @@ export {
35
35
  setCookie,
36
36
  setSignedCookie,
37
37
  } from "./http/cookies.js";
38
+ export {
39
+ clearSharedCookie,
40
+ requestIsHttps,
41
+ resolveSharedCookieDomain,
42
+ SHARED_COOKIE_WARN_BYTES,
43
+ writeSharedCookie,
44
+ } from "./http/shared-cookie.js";
38
45
  export { reportUpstreamFailure } from "./server/upstream-tracking.js";
39
46
  export { asset, hasAsset, optimizedImage, getSpriteIds } from "./server/assets.js";
40
47
  export { remoteImageUrl, parseAllowedRemoteUrl } from "./server/image-optimizer.js";
@@ -0,0 +1,226 @@
1
+ /**
2
+ * Alt alan adı handoff: kısa ömürlü tek kullanımlık bilet.
3
+ *
4
+ * Akış:
5
+ * 1. Paylaşımlı cookie yazımı başarısız (istemci read-back fail)
6
+ * 2. POST /_jskelet/auth/handoff { name, value, next }
7
+ * 3. Yanıt { url: "https://tr.…/path?handoff=TICKET" }
8
+ * 4. Hedef host'ta GET middleware bileti tüketir → Set-Cookie + 303
9
+ *
10
+ * Bilet süreç belleğinde; tek süreçte tüm locale host'ları servis eden
11
+ * kurulumlar için yeterli. Değer kısa session id olmalı.
12
+ */
13
+ import { getConfig } from "../../config/index.js";
14
+ import { randomToken, setCookie } from "../../http/cookies.js";
15
+ import {
16
+ SHARED_COOKIE_WARN_BYTES,
17
+ requestIsHttps,
18
+ writeSharedCookie,
19
+ } from "../../http/shared-cookie.js";
20
+ import { resolveSharedCookieDomain } from "../../shared/cookie-domain.js";
21
+
22
+ /** @type {Map<string, { name: string, value: string, maxAge?: number, exp: number }>} */
23
+ const tickets = new Map();
24
+
25
+ const DEFAULT_TTL_SECONDS = 60;
26
+ const HANDOFF_PATH = "/_jskelet/auth/handoff";
27
+
28
+ /**
29
+ * @returns {{ enabled: boolean, ttlSeconds: number, path: string, maxValueBytes: number }}
30
+ */
31
+ function settings() {
32
+ const auth = getConfig().auth ?? {};
33
+ const raw = auth.crossSubdomainHandoff;
34
+ if (raw === false || raw == null) {
35
+ return {
36
+ enabled: false,
37
+ ttlSeconds: DEFAULT_TTL_SECONDS,
38
+ path: HANDOFF_PATH,
39
+ maxValueBytes: SHARED_COOKIE_WARN_BYTES,
40
+ };
41
+ }
42
+
43
+ if (raw === true) {
44
+ return {
45
+ enabled: true,
46
+ ttlSeconds: DEFAULT_TTL_SECONDS,
47
+ path: HANDOFF_PATH,
48
+ maxValueBytes: SHARED_COOKIE_WARN_BYTES,
49
+ };
50
+ }
51
+
52
+ const source = /** @type {Record<string, unknown>} */ (raw);
53
+ const ttl = Number(source.ttlSeconds);
54
+ const maxBytes = Number(source.maxValueBytes);
55
+ return {
56
+ enabled: source.enabled !== false,
57
+ ttlSeconds:
58
+ Number.isFinite(ttl) && ttl > 0 ? Math.min(300, Math.floor(ttl)) : DEFAULT_TTL_SECONDS,
59
+ path:
60
+ typeof source.path === "string" && source.path.startsWith("/")
61
+ ? source.path
62
+ : HANDOFF_PATH,
63
+ maxValueBytes:
64
+ Number.isFinite(maxBytes) && maxBytes > 0
65
+ ? Math.floor(maxBytes)
66
+ : SHARED_COOKIE_WARN_BYTES,
67
+ };
68
+ }
69
+
70
+ function sweep() {
71
+ const now = Date.now();
72
+ for (const [id, entry] of tickets) {
73
+ if (entry.exp <= now) tickets.delete(id);
74
+ }
75
+ }
76
+
77
+ /**
78
+ * @param {string} next
79
+ * @param {import('express').Request} req
80
+ * @returns {URL | null}
81
+ */
82
+ function parseNext(next, req) {
83
+ try {
84
+ return new URL(next, `${req.protocol}://${req.get("host")}`);
85
+ } catch {
86
+ return null;
87
+ }
88
+ }
89
+
90
+ /**
91
+ * @param {URL} url
92
+ * @param {import('express').Request} req
93
+ * @returns {boolean}
94
+ */
95
+ function nextAllowed(url, req) {
96
+ if (url.protocol !== "http:" && url.protocol !== "https:") return false;
97
+
98
+ const roots = getConfig().brand?.sharedCookieRoots ?? [];
99
+ const targetHost = url.hostname.toLowerCase();
100
+ const sourceHost = String(req.hostname || "")
101
+ .toLowerCase()
102
+ .replace(/:\d+$/, "");
103
+
104
+ if (targetHost === sourceHost) return true;
105
+
106
+ const sourceRoot = resolveSharedCookieDomain(sourceHost, roots);
107
+ const targetRoot = resolveSharedCookieDomain(targetHost, roots);
108
+ return Boolean(sourceRoot && targetRoot && sourceRoot === targetRoot);
109
+ }
110
+
111
+ /**
112
+ * @param {import('express').Request} req
113
+ * @param {import('express').Response} res
114
+ * @param {{ name: string, value: string, maxAge?: number }} payload
115
+ * @returns {void}
116
+ */
117
+ function issueCookie(req, res, payload) {
118
+ const shared = writeSharedCookie(res, payload.name, payload.value, {
119
+ req,
120
+ maxAge: payload.maxAge,
121
+ });
122
+
123
+ if (shared.ok) return;
124
+
125
+ setCookie(res, payload.name, payload.value, {
126
+ path: "/",
127
+ httpOnly: true,
128
+ sameSite: "Lax",
129
+ secure: requestIsHttps(req),
130
+ maxAge: payload.maxAge,
131
+ });
132
+ }
133
+
134
+ /**
135
+ * @param {string} ticketId
136
+ * @returns {{ name: string, value: string, maxAge?: number } | null}
137
+ */
138
+ function redeem(ticketId) {
139
+ sweep();
140
+ const entry = tickets.get(ticketId);
141
+ if (!entry) return null;
142
+ tickets.delete(ticketId);
143
+ if (entry.exp <= Date.now()) return null;
144
+ return { name: entry.name, value: entry.value, maxAge: entry.maxAge };
145
+ }
146
+
147
+ /**
148
+ * Handoff uçlarını mount eder. `auth.crossSubdomainHandoff` kapalıysa no-op.
149
+ *
150
+ * @param {import('express').Express} app
151
+ * @returns {void}
152
+ */
153
+ export function mountAuthHandoff(app) {
154
+ const cfg = settings();
155
+ if (!cfg.enabled) return;
156
+
157
+ app.post(cfg.path, (req, res) => {
158
+ sweep();
159
+
160
+ const name = typeof req.body?.name === "string" ? req.body.name.trim() : "";
161
+ const value = typeof req.body?.value === "string" ? req.body.value : "";
162
+ const next = typeof req.body?.next === "string" ? req.body.next.trim() : "";
163
+ const maxAge =
164
+ req.body?.maxAge != null && Number.isFinite(Number(req.body.maxAge))
165
+ ? Math.floor(Number(req.body.maxAge))
166
+ : undefined;
167
+
168
+ if (!name || !next) {
169
+ res.status(400).json({ ok: false, error: "name and next are required" });
170
+ return;
171
+ }
172
+
173
+ if (Buffer.byteLength(value, "utf8") > cfg.maxValueBytes) {
174
+ res.status(400).json({
175
+ ok: false,
176
+ error: `value exceeds ${cfg.maxValueBytes} bytes; use a short session id`,
177
+ });
178
+ return;
179
+ }
180
+
181
+ const nextUrl = parseNext(next, req);
182
+ if (!nextUrl || !nextAllowed(nextUrl, req)) {
183
+ res.status(400).json({ ok: false, error: "next is not an allowed URL" });
184
+ return;
185
+ }
186
+
187
+ const id = randomToken(24);
188
+ tickets.set(id, {
189
+ name,
190
+ value,
191
+ maxAge,
192
+ exp: Date.now() + cfg.ttlSeconds * 1000,
193
+ });
194
+
195
+ nextUrl.searchParams.set("handoff", id);
196
+ res.setHeader("Cache-Control", "no-store");
197
+ res.json({ ok: true, url: nextUrl.href });
198
+ });
199
+
200
+ // Hedef host'ta `?handoff=` yakala — route render'ından önce.
201
+ app.use((req, res, next) => {
202
+ if (req.method !== "GET" && req.method !== "HEAD") return next();
203
+ const ticket = typeof req.query?.handoff === "string" ? req.query.handoff : "";
204
+ if (!ticket) return next();
205
+
206
+ const payload = redeem(ticket);
207
+ if (!payload) return next();
208
+
209
+ issueCookie(req, res, payload);
210
+
211
+ const url = new URL(req.originalUrl || req.url, `${req.protocol}://${req.get("host")}`);
212
+ url.searchParams.delete("handoff");
213
+ res.setHeader("Cache-Control", "no-store");
214
+ res.redirect(303, url.pathname + url.search + url.hash);
215
+ });
216
+ }
217
+
218
+ /** @returns {void} */
219
+ export function _resetHandoffTickets() {
220
+ tickets.clear();
221
+ }
222
+
223
+ /** @returns {number} */
224
+ export function _handoffTicketCount() {
225
+ return tickets.size;
226
+ }
@@ -0,0 +1,113 @@
1
+ /**
2
+ * HTML cache anahtarına host / header / özel fn ile sabit vary parçası ekler.
3
+ *
4
+ * CDN zaten tam URL ile ayırır; asıl risk origin L1 ve Redis HTML anahtarı —
5
+ * host'tan locale üreten sitelerde `vary.host: true` olmadan ilk locale'in
6
+ * HTML'i diğer host'a servis edilir.
7
+ */
8
+ import { getConfig } from "../config/index.js";
9
+
10
+ /**
11
+ * Public Host: `x-forwarded-host` (ilk değer) yoksa `Host`. Lowercase, portsuz.
12
+ * IPv6 (`[::1]:3000`) köşeli parantezleri korur.
13
+ *
14
+ * @param {{ headers?: Record<string, unknown>, get?: (name: string) => string | undefined }} req
15
+ * @returns {string}
16
+ */
17
+ export function publicHost(req) {
18
+ const forwarded = headerValue(req, "x-forwarded-host");
19
+ const raw = forwarded || headerValue(req, "host") || "";
20
+ const first = raw.split(",")[0].trim().toLowerCase();
21
+ return stripPort(first);
22
+ }
23
+
24
+ /**
25
+ * @param {string} host
26
+ * @returns {string}
27
+ */
28
+ function stripPort(host) {
29
+ if (!host) return "";
30
+ if (host.startsWith("[")) {
31
+ const end = host.indexOf("]");
32
+ return end === -1 ? host : host.slice(0, end + 1);
33
+ }
34
+ // Birden fazla `:` → portsuz IPv6 (Host'ta nadir); tek `:` → host:port.
35
+ const colon = host.lastIndexOf(":");
36
+ if (colon === -1) return host;
37
+ if (host.indexOf(":") !== colon) return host;
38
+ return host.slice(0, colon);
39
+ }
40
+
41
+ /**
42
+ * @param {{ headers?: Record<string, unknown>, get?: (name: string) => string | undefined }} req
43
+ * @param {string} name
44
+ * @returns {string}
45
+ */
46
+ function headerValue(req, name) {
47
+ const viaGet = req.get?.(name);
48
+ if (typeof viaGet === "string" && viaGet) return viaGet;
49
+
50
+ const raw = req.headers?.[name.toLowerCase()];
51
+ if (Array.isArray(raw)) return raw[0] ? String(raw[0]) : "";
52
+ if (raw == null) return "";
53
+ return String(raw);
54
+ }
55
+
56
+ /**
57
+ * Anahtarın başına eklenen önek: `h=tr.example.com|` veya
58
+ * `h=…&x-locale=tr|`. Vary yoksa boş string.
59
+ *
60
+ * @param {{ headers?: Record<string, unknown>, get?: (name: string) => string | undefined }} [req]
61
+ * @returns {string}
62
+ */
63
+ export function buildVaryPrefix(req) {
64
+ if (!req) return "";
65
+
66
+ let vary;
67
+ try {
68
+ vary = getConfig().cacheVary;
69
+ } catch {
70
+ return "";
71
+ }
72
+
73
+ if (!vary || (!vary.host && !vary.headers.length && !vary.fn)) return "";
74
+
75
+ /** @type {string[]} */
76
+ const parts = [];
77
+
78
+ if (vary.host) {
79
+ const host = publicHost(req);
80
+ if (host) parts.push(`h=${host}`);
81
+ }
82
+
83
+ for (const name of vary.headers) {
84
+ const value = headerValue(req, name).trim();
85
+ if (value) parts.push(`${name}=${value}`);
86
+ }
87
+
88
+ if (typeof vary.fn === "function") {
89
+ try {
90
+ const custom = vary.fn(/** @type {import('express').Request} */ (req));
91
+ if (custom != null && custom !== "") parts.push(String(custom));
92
+ } catch (error) {
93
+ console.warn("[cache] cache().vary.fn threw, ignoring it", error);
94
+ }
95
+ }
96
+
97
+ return parts.length ? `${parts.join("&")}|` : "";
98
+ }
99
+
100
+ /**
101
+ * Anahtardan yol kısmını çıkarır (`[vary|]yol?query` → `yol`).
102
+ * Invalidation hedefleri `/…` ile başlar; vary öneki eşleşmeye karışmamalı.
103
+ *
104
+ * @param {string} key
105
+ * @returns {string}
106
+ */
107
+ export function pathOfCacheKey(key) {
108
+ const mark = key.indexOf("?");
109
+ const beforeQuery = mark === -1 ? key : key.slice(0, mark);
110
+ const sep = beforeQuery.indexOf("|/");
111
+ if (sep !== -1) return beforeQuery.slice(sep + 1);
112
+ return beforeQuery;
113
+ }
@@ -21,6 +21,9 @@
21
21
  * body parser'dan önce, admin ile aynı katmanda.
22
22
  * 5. body parser'lar — statikten sonra: görsel isteklerinde gövde ayrıştırma
23
23
  * maliyeti ödenmesin.
24
+ * 5b. auth handoff (açıksa) — POST bilet + `?handoff=` redeem; csrf'den
25
+ * önce değil sonra: JSON gövde parser'ı hazır olsun. Redeem GET route
26
+ * render'ından önce olmalı.
24
27
  * 6. csrf — body parser'lardan sonra olmalı: token form alanından okunuyor.
25
28
  * Rewrite'lardan önce, çünkü kontrol istemcinin gördüğü yola bakar.
26
29
  * 7. rewrites(afterFiles) — statik denendikten sonra, sayfalardan önce.
@@ -149,6 +152,17 @@ export async function createApp(options = {}) {
149
152
  app.use(express.urlencoded({ extended: false, limit: "64kb" }));
150
153
  app.use(express.json({ limit: "256kb" }));
151
154
 
155
+ const handoff = config.auth?.crossSubdomainHandoff;
156
+ if (
157
+ handoff === true ||
158
+ (handoff &&
159
+ typeof handoff === "object" &&
160
+ /** @type {{ enabled?: boolean }} */ (handoff).enabled !== false)
161
+ ) {
162
+ const { mountAuthHandoff } = await import("./auth/handoff.js");
163
+ mountAuthHandoff(app);
164
+ }
165
+
152
166
  app.use(csrf());
153
167
 
154
168
  app.use(configRewrites("afterFiles"));
@@ -34,6 +34,7 @@ import { getConfig } from "../config/index.js";
34
34
  import { DEFAULT_HTML_CACHE_MAX_ENTRIES } from "../config/defaults.js";
35
35
  import { collectDependencies } from "./cache-deps.js";
36
36
  import { compilePattern, matchPattern } from "../config/pattern.js";
37
+ import { pathOfCacheKey } from "./cache-vary.js";
37
38
  import {
38
39
  cacheKey,
39
40
  onCacheEvent,
@@ -662,8 +663,9 @@ function invalidateKey(key, hard) {
662
663
  * arkada ve anahtar başına tek seferde koşar. `hard: true` yalnızca eski
663
664
  * HTML'in gerçekten geçersiz olduğu durumlar için.
664
665
  *
665
- * Anahtar `yol?query` olduğundan eşleştirme **yol kısmına** yapılır: bir
666
- * yolun bütün query varyantları (`?utm_source=…` dahil) tek çağrıyla düşer.
666
+ * Anahtar `yol?query` (isteğe bağlı `vary|` önekiyle) olduğundan eşleştirme
667
+ * **yol kısmına** yapılır: bir yolun bütün query / host varyantları tek
668
+ * çağrıyla düşer.
667
669
  *
668
670
  * @param {string | RegExp | (string | RegExp)[]} target
669
671
  * @param {{ hard?: boolean }} [options]
@@ -733,14 +735,14 @@ function compileMatchers(targets) {
733
735
  }
734
736
 
735
737
  /**
736
- * Anahtar `yol?query`; eşleştirme **yol kısmına** yapılır.
738
+ * Anahtar `[vary|]yol?query`; eşleştirme **yol** kısmına yapılır.
739
+ * Vary öneki (`h=…|`) invalidation hedefiyle karışmasın.
737
740
  *
738
741
  * @param {string} key
739
742
  * @returns {string}
740
743
  */
741
744
  function pathOf(key) {
742
- const mark = key.indexOf("?");
743
- return mark === -1 ? key : key.slice(0, mark);
745
+ return pathOfCacheKey(key);
744
746
  }
745
747
 
746
748
  /**
@@ -879,6 +881,9 @@ function dropLocalKey(key) {
879
881
  * boşaltır. Isıtma turu bunları başa alır; iki tur aynı yolu tekrar
880
882
  * ısıtmasın diye okuma yıkıcıdır.
881
883
  *
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.
886
+ *
882
887
  * @returns {string[]}
883
888
  */
884
889
  export function takeInvalidatedPaths() {
@@ -886,8 +891,13 @@ export function takeInvalidatedPaths() {
886
891
 
887
892
  const paths = [...invalidated];
888
893
  invalidated.clear();
889
- // Anahtar `yol?query`; query boşsa sondaki `?` atılır.
890
- return paths.map((key) => (key.endsWith("?") ? key.slice(0, -1) : key));
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
+ });
891
901
  }
892
902
 
893
903
  /**
@@ -24,6 +24,65 @@ import { getDataCacheStats } from "./data-cache.js";
24
24
  import { isTransientStatus } from "./upstream-tracking.js";
25
25
  import { upstreamCooldownMs } from "./upstream-limiter.js";
26
26
 
27
+ /**
28
+ * `cache().prewarm.origins` yoksa loopback. Port'suz origin'lere dinleme
29
+ * portu eklenir — `http://tr.localhost` → `http://tr.localhost:3000`.
30
+ *
31
+ * @param {number} port
32
+ * @returns {string[]}
33
+ */
34
+ function resolvePrewarmOrigins(port) {
35
+ const configured = getConfig().prewarm?.origins;
36
+ const list = Array.isArray(configured)
37
+ ? configured.filter((value) => typeof value === "string" && value)
38
+ : [];
39
+
40
+ if (!list.length) return [`http://127.0.0.1:${port}`];
41
+
42
+ return list.map((origin) => {
43
+ try {
44
+ const url = new URL(origin);
45
+ if (!url.port) url.port = String(port);
46
+ return url.origin;
47
+ } catch {
48
+ return origin;
49
+ }
50
+ });
51
+ }
52
+
53
+ /**
54
+ * Vary açıkken ısıtma isteği ziyaretçinin Host'unu taşısın — aksi halde
55
+ * loopback anahtarı ısınır, gerçek locale host soğuk kalır.
56
+ *
57
+ * @param {{ get?: (name: string) => string | undefined,
58
+ * headers?: Record<string, unknown>, protocol?: string } | undefined} req
59
+ * @param {string} fallback
60
+ * @returns {string}
61
+ */
62
+ function originFromRequest(req, fallback) {
63
+ if (!req) return fallback;
64
+
65
+ let vary;
66
+ try {
67
+ vary = getConfig().cacheVary;
68
+ } catch {
69
+ return fallback;
70
+ }
71
+ if (!vary?.host && !vary?.headers?.length && !vary?.fn) return fallback;
72
+
73
+ const host =
74
+ req.get?.("host") ||
75
+ (typeof req.headers?.host === "string" ? req.headers.host : "");
76
+ if (!host) return fallback;
77
+
78
+ const forwarded = req.headers?.["x-forwarded-proto"];
79
+ const protoRaw = Array.isArray(forwarded)
80
+ ? forwarded[0]
81
+ : forwarded || req.protocol || "http";
82
+ const proto = String(protoRaw).split(",")[0].trim() || "http";
83
+ return `${proto}://${host}`;
84
+ }
85
+
27
86
  /** Klasik turu yöneten env'ler; `onVisit` ile birlikte yasak. */
28
87
  const CLASSIC_PREWARM_ENV = [
29
88
  "PREWARM_MAX",
@@ -715,6 +774,9 @@ export function noteVisitWarm(html, context) {
715
774
  /** @type {string | undefined} */ (context.req?.headers?.["user-agent"]);
716
775
  if (ua && ua === getConfig().brand.prewarmUserAgent) return;
717
776
 
777
+ // Vary açıksa bu ziyaretin Host'u üzerinden ısıt — loopback anahtarı değil.
778
+ visitOrigin = originFromRequest(context.req, visitOrigin);
779
+
718
780
  const perPage = Number(getConfig().prewarm?.onVisit?.perPage) || 20;
719
781
  const links = extractSameOriginLinks(html, {
720
782
  limit: perPage,
@@ -796,12 +858,13 @@ export function startPrewarm({ port }) {
796
858
  const config = getConfig();
797
859
  if (process.env.PREWARM === "0") return;
798
860
 
799
- const origin = `http://127.0.0.1:${port}`;
861
+ const origins = resolvePrewarmOrigins(port);
862
+ const origin = origins[0];
800
863
 
801
864
  // Klasik `prewarmPaths` olmasa da TTL öncesi soft-bayatlayan girdiler
802
865
  // HTTP ile ısıtılsın. `PREWARM=0` yukarıda her şeyi keser.
803
866
  startEarlyExpirySweep();
804
- startExpiryWarmDrain(origin);
867
+ startExpiryWarmDrain(origins);
805
868
 
806
869
  if (config.prewarm?.onVisit?.enabled) {
807
870
  const classicEnv = CLASSIC_PREWARM_ENV.filter((key) => process.env[key]);
@@ -833,7 +896,10 @@ export function startPrewarm({ port }) {
833
896
  if (running) return;
834
897
  running = true;
835
898
  try {
836
- await prewarm({ origin });
899
+ // `vary.host` açıkken her origin ayrı anahtar ısıtır.
900
+ for (const next of origins) {
901
+ await prewarm({ origin: next });
902
+ }
837
903
  } catch (error) {
838
904
  console.error("[prewarm] failed", error);
839
905
  } finally {
@@ -856,8 +922,8 @@ export function startPrewarm({ port }) {
856
922
  if (interval > 0) setInterval(() => void run(), interval * 1000).unref();
857
923
  }
858
924
 
859
- /** @type {string | null} */
860
- let expiryOrigin = null;
925
+ /** @type {string[]} */
926
+ let expiryOrigins = [];
861
927
 
862
928
  /** @type {boolean} */
863
929
  let expiryDraining = false;
@@ -869,11 +935,11 @@ let expiryDrainTimer = null;
869
935
  * Soft-bayat / invalidate kuyruğunu periyodik boşaltır. Klasik tur ve onVisit
870
936
  * aynı kuyruğu da okur; bu drain `prewarmPaths` yokken de çalışır.
871
937
  *
872
- * @param {string} origin
938
+ * @param {string[]} origins
873
939
  * @returns {void}
874
940
  */
875
- function startExpiryWarmDrain(origin) {
876
- expiryOrigin = origin;
941
+ function startExpiryWarmDrain(origins) {
942
+ expiryOrigins = origins.length ? origins : [];
877
943
  if (expiryDrainTimer) return;
878
944
  expiryDrainTimer = setInterval(() => {
879
945
  void drainExpiryWarm();
@@ -885,7 +951,7 @@ function startExpiryWarmDrain(origin) {
885
951
  * @returns {Promise<void>}
886
952
  */
887
953
  async function drainExpiryWarm() {
888
- if (expiryDraining || !expiryOrigin) return;
954
+ if (expiryDraining || !expiryOrigins.length) return;
889
955
 
890
956
  const paths = takeInvalidatedPaths();
891
957
  if (!paths.length) return;
@@ -895,7 +961,9 @@ async function drainExpiryWarm() {
895
961
  const cold = paths.filter((path) => !isHtmlCacheFresh(path));
896
962
  if (!cold.length) return;
897
963
 
898
- await prewarm({ origin: expiryOrigin, paths: cold, quiet: true });
964
+ for (const origin of expiryOrigins) {
965
+ await prewarm({ origin, paths: cold, quiet: true });
966
+ }
899
967
  } catch (error) {
900
968
  console.error("[prewarm] expiry warm failed", error);
901
969
  } finally {
@@ -18,6 +18,7 @@ import fs from "node:fs";
18
18
  import { pathToFileURL } from "node:url";
19
19
  import ejs from "ejs";
20
20
  import { withHtmlCache } from "./html-cache.js";
21
+ import { buildVaryPrefix } from "./cache-vary.js";
21
22
  import { getConfig, hook } from "../config/index.js";
22
23
  import { matchPattern } from "../config/pattern.js";
23
24
  import { encodeText, negotiateEncoding } from "./middleware/compression.js";
@@ -329,12 +330,14 @@ export function route(controller, options = {}) {
329
330
  // Anahtar `null` ise query bu yol için cache'lenebilir değil: sayfa
330
331
  // dinamik davranır. Anahtar yine de gerekiyor (hata sayfası ölçümü,
331
332
  // teşhis) ama TTL sıfırlanıp cache yolu kapatılır.
332
- const key = buildCacheKey(req.path, ctx.query);
333
+ const key = buildCacheKey(req.path, ctx.query, req);
333
334
  const cacheable =
334
335
  !isPrivate && req.method === "GET" && Boolean(revalidate) && key !== null;
335
- const cacheKey = key ?? `${req.path}?${new URLSearchParams(
336
- Object.entries(ctx.query).map(([k, v]) => [k, String(v)]),
337
- ).toString()}`;
336
+ const cacheKey =
337
+ key ??
338
+ `${buildVaryPrefix(req)}${req.path}?${new URLSearchParams(
339
+ Object.entries(ctx.query).map(([k, v]) => [k, String(v)]),
340
+ ).toString()}`;
338
341
 
339
342
  try {
340
343
  const result = await withRequestContext(context, () =>
@@ -582,6 +585,10 @@ function resolveQueryPolicy(pathname) {
582
585
  /**
583
586
  * HTML cache anahtarı, ya da query bu yol için cache'lenebilir değilse `null`.
584
587
  *
588
+ * Biçim: `${varyPrefix}${pathname}?${izin verilen query}`.
589
+ * Vary (`cache().vary`) query allowlist'ten bağımsız; host/locale sitelerinde
590
+ * ilk host'un HTML'inin diğerine servis edilmesini engeller.
591
+ *
585
592
  * Varsayılan olarak query parametresi taşıyan istek dinamiktir: `cache().query`
586
593
  * altında eşleşen bir kural olmadıkça cache'e hiç girmez. Aksi hâlde bir yolun
587
594
  * bütün `?utm_source=…` varyantları ayrı girdi olur ve LRU'daki gerçek
@@ -592,11 +599,13 @@ function resolveQueryPolicy(pathname) {
592
599
  *
593
600
  * @param {string} pathname
594
601
  * @param {Record<string, unknown>} query
602
+ * @param {{ headers?: Record<string, unknown>, get?: (name: string) => string | undefined }} [req]
595
603
  * @returns {string | null}
596
604
  */
597
- function buildCacheKey(pathname, query) {
605
+ function buildCacheKey(pathname, query, req) {
606
+ const vary = buildVaryPrefix(req);
598
607
  const entries = Object.entries(query);
599
- if (!entries.length) return `${pathname}?`;
608
+ if (!entries.length) return `${vary}${pathname}?`;
600
609
 
601
610
  const policy = resolveQueryPolicy(pathname);
602
611
  if (policy === null) return null;
@@ -612,7 +621,7 @@ function buildCacheKey(pathname, query) {
612
621
  .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)),
613
622
  );
614
623
 
615
- return `${pathname}?${params.toString()}`;
624
+ return `${vary}${pathname}?${params.toString()}`;
616
625
  }
617
626
 
618
627
  /**