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/CHANGELOG.md +23 -0
- package/docs/06-cache.md +62 -11
- package/docs/07-yapilandirma.md +54 -3
- package/docs/12-panel-ve-oturum.md +88 -0
- package/docs/en/06-caching.md +63 -12
- package/docs/en/07-configuration.md +56 -3
- package/docs/en/12-dashboards-and-sessions.md +91 -0
- package/package.json +2 -2
- package/src/client/index.js +10 -0
- package/src/client/shared-cookie.js +225 -0
- package/src/config/defaults.js +27 -0
- package/src/config/index.js +108 -3
- package/src/http/cookies-entry.js +20 -0
- package/src/http/cookies.js +2 -0
- package/src/http/shared-cookie.js +178 -0
- package/src/index.js +7 -0
- package/src/server/auth/handoff.js +226 -0
- package/src/server/cache-vary.js +113 -0
- package/src/server/create-app.js +14 -0
- package/src/server/html-cache.js +17 -7
- package/src/server/prewarm.js +78 -10
- package/src/server/render.js +16 -7
- package/src/shared/cookie-domain.js +66 -0
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
|
+
}
|
package/src/server/create-app.js
CHANGED
|
@@ -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"));
|
package/src/server/html-cache.js
CHANGED
|
@@ -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`
|
|
666
|
-
* yolun bütün query
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
890
|
-
|
|
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
|
/**
|
package/src/server/prewarm.js
CHANGED
|
@@ -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
|
|
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(
|
|
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
|
-
|
|
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
|
|
860
|
-
let
|
|
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}
|
|
938
|
+
* @param {string[]} origins
|
|
873
939
|
* @returns {void}
|
|
874
940
|
*/
|
|
875
|
-
function startExpiryWarmDrain(
|
|
876
|
-
|
|
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 || !
|
|
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
|
-
|
|
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 {
|
package/src/server/render.js
CHANGED
|
@@ -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 =
|
|
336
|
-
|
|
337
|
-
|
|
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
|
/**
|