jskelet 0.1.2 → 0.1.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.
- package/CHANGELOG.md +68 -4
- package/docs/06-cache.md +201 -19
- package/docs/07-yapilandirma.md +57 -6
- package/docs/en/06-caching.md +206 -20
- package/docs/en/07-configuration.md +59 -6
- package/package.json +1 -1
- package/src/config/defaults.js +49 -0
- package/src/config/index.js +61 -5
- package/src/index.js +7 -0
- package/src/server/data-cache.js +244 -0
- package/src/server/dev/report.js +8 -1
- package/src/server/html-cache.js +22 -2
- package/src/server/prewarm.js +159 -14
- package/src/server/render.js +65 -12
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Upstream veri için TTL + stale-while-revalidate LRU önbelleği.
|
|
3
|
+
*
|
|
4
|
+
* HTML önbelleği (`html-cache.js`) yalnızca **trafiği olan** sayfaları tutar:
|
|
5
|
+
* girdi başına yüz kilobayt düştüğü için sınırı 500 civarındadır ve on binlerce
|
|
6
|
+
* yolluk bir site onu ısıtmaya çalıştığında kendi ısıttığını siler. Uzun kuyruk
|
|
7
|
+
* için doğru katman bu modül: aynı sayfanın JSON'u HTML'inden onlarca kat
|
|
8
|
+
* küçük olduğu için on binlerce girdi bellekte durur.
|
|
9
|
+
*
|
|
10
|
+
* Kazanç iki taraflı:
|
|
11
|
+
* - Hiç ısıtılmamış bir uzun kuyruk sayfası ilk ziyaretçide render edilir ama
|
|
12
|
+
* upstream'e gitmez; gecikme yüzlerce ms değil, şablon render'ı kadardır.
|
|
13
|
+
* - Periyodik ısıtma turları API kotası harcamaz, veri katmanından okur.
|
|
14
|
+
*
|
|
15
|
+
* `null`/`undefined` **saklanmaz**: uygulamaların HTTP istemcisi hatada
|
|
16
|
+
* genellikle `null` döner ve bunu saklamak, geçici bir 429'u TTL boyunca "veri
|
|
17
|
+
* yok" hâline dondurmak olurdu. Boş cevabı bilinçli olarak saklamak isteyen
|
|
18
|
+
* `storeEmpty: true` verir.
|
|
19
|
+
*/
|
|
20
|
+
import { getConfig } from "../config/index.js";
|
|
21
|
+
import { DEFAULT_DATA_CACHE } from "../config/defaults.js";
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* @typedef {{ value: unknown, expiresAt: number, staleUntil: number }} DataEntry
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
/** @type {Map<string, DataEntry>} */
|
|
28
|
+
const store = new Map();
|
|
29
|
+
|
|
30
|
+
/** @type {Map<string, Promise<unknown>>} */
|
|
31
|
+
const inflight = new Map();
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Ayarlar config'ten okunur ama config yüklenmemiş olabilir: bu modül
|
|
35
|
+
* script'lerden ve testlerden de çağrılabiliyor. `getConfig()` fırlatırsa
|
|
36
|
+
* kod varsayılanına düşülür.
|
|
37
|
+
*
|
|
38
|
+
* @returns {{ maxEntries: number, staleFactor: number }}
|
|
39
|
+
*/
|
|
40
|
+
function settings() {
|
|
41
|
+
try {
|
|
42
|
+
const { data } = getConfig();
|
|
43
|
+
return {
|
|
44
|
+
maxEntries: Number(data?.maxEntries) || DEFAULT_DATA_CACHE.maxEntries,
|
|
45
|
+
staleFactor: Number.isFinite(Number(data?.staleFactor))
|
|
46
|
+
? Number(data.staleFactor)
|
|
47
|
+
: DEFAULT_DATA_CACHE.staleFactor,
|
|
48
|
+
};
|
|
49
|
+
} catch {
|
|
50
|
+
return { ...DEFAULT_DATA_CACHE };
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* @param {string} key
|
|
56
|
+
* @returns {{ value: unknown, stale: boolean } | null}
|
|
57
|
+
*/
|
|
58
|
+
function read(key) {
|
|
59
|
+
const entry = store.get(key);
|
|
60
|
+
if (!entry) return null;
|
|
61
|
+
|
|
62
|
+
const now = Date.now();
|
|
63
|
+
if (now >= entry.staleUntil) {
|
|
64
|
+
store.delete(key);
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// LRU: erişilen girdiyi sona taşı.
|
|
69
|
+
store.delete(key);
|
|
70
|
+
store.set(key, entry);
|
|
71
|
+
|
|
72
|
+
return { value: entry.value, stale: now >= entry.expiresAt };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* @param {string} key
|
|
77
|
+
* @param {unknown} value
|
|
78
|
+
* @param {number} ttlSeconds
|
|
79
|
+
* @param {number} staleFactor
|
|
80
|
+
*/
|
|
81
|
+
function write(key, value, ttlSeconds, staleFactor) {
|
|
82
|
+
const now = Date.now();
|
|
83
|
+
const ttl = ttlSeconds * 1000;
|
|
84
|
+
|
|
85
|
+
store.set(key, {
|
|
86
|
+
value,
|
|
87
|
+
expiresAt: now + ttl,
|
|
88
|
+
staleUntil: now + ttl + ttl * staleFactor,
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
const { maxEntries } = settings();
|
|
92
|
+
while (store.size > maxEntries) {
|
|
93
|
+
const oldest = store.keys().next().value;
|
|
94
|
+
if (oldest === undefined) break;
|
|
95
|
+
store.delete(oldest);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* @param {string} key
|
|
101
|
+
* @param {number} ttlSeconds
|
|
102
|
+
* @param {() => Promise<unknown>} producer
|
|
103
|
+
* @param {{ storeEmpty?: boolean, staleFactor?: number }} options
|
|
104
|
+
* @returns {Promise<unknown>}
|
|
105
|
+
*/
|
|
106
|
+
function refresh(key, ttlSeconds, producer, options) {
|
|
107
|
+
// Aynı anahtarı eşzamanlı isteyen yüz sayfa tek upstream isteğine düşer.
|
|
108
|
+
// Isıtma turlarında bu tek başına kotanın büyük kısmını kurtarıyor.
|
|
109
|
+
const pending = inflight.get(key);
|
|
110
|
+
if (pending) return pending;
|
|
111
|
+
|
|
112
|
+
const staleFactor = options.staleFactor ?? settings().staleFactor;
|
|
113
|
+
|
|
114
|
+
const task = Promise.resolve()
|
|
115
|
+
.then(producer)
|
|
116
|
+
.then((value) => {
|
|
117
|
+
const empty = value === undefined || value === null;
|
|
118
|
+
if (!empty || options.storeEmpty === true) {
|
|
119
|
+
write(key, value, ttlSeconds, staleFactor);
|
|
120
|
+
}
|
|
121
|
+
return value;
|
|
122
|
+
})
|
|
123
|
+
.finally(() => {
|
|
124
|
+
inflight.delete(key);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
inflight.set(key, task);
|
|
128
|
+
return task;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Veriyi önbellekten döner, gerekiyorsa `producer` ile üretir.
|
|
133
|
+
*
|
|
134
|
+
* @param {string} key Anahtar tamamen uygulamanın; sürüm/dil gibi ayrımlar
|
|
135
|
+
* anahtara yazılır (`quote:v2:${symbol}`).
|
|
136
|
+
* @param {number} ttlSeconds 0 → önbellek yok, `producer` her çağrıda çalışır.
|
|
137
|
+
* @param {() => Promise<T>} producer
|
|
138
|
+
* @param {{ storeEmpty?: boolean, staleFactor?: number }} [options]
|
|
139
|
+
* `storeEmpty` boş cevabı da saklar, `staleFactor` bu anahtar için bayat
|
|
140
|
+
* penceresini ayarlar (0 → bayat servis yok).
|
|
141
|
+
* @returns {Promise<T>}
|
|
142
|
+
* @template T
|
|
143
|
+
*/
|
|
144
|
+
export async function withDataCache(key, ttlSeconds, producer, options = {}) {
|
|
145
|
+
if (!ttlSeconds) return producer();
|
|
146
|
+
|
|
147
|
+
const hit = read(key);
|
|
148
|
+
|
|
149
|
+
if (hit) {
|
|
150
|
+
// Bayat girdi anında döner; tazeleme arkada yürür ve hatası bu isteği
|
|
151
|
+
// etkilemez — çağıran taraf bir şey beklemediği için upstream'in yavaş
|
|
152
|
+
// olması sayfaya yansımaz.
|
|
153
|
+
if (hit.stale) {
|
|
154
|
+
void refresh(key, ttlSeconds, producer, options).catch((error) => {
|
|
155
|
+
console.error(`[data-cache] background refresh failed: ${key}`, error);
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
return /** @type {T} */ (hit.value);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
try {
|
|
162
|
+
return /** @type {T} */ (await refresh(key, ttlSeconds, producer, options));
|
|
163
|
+
} catch (error) {
|
|
164
|
+
// Girdi yoksa hata çağırana gider; asıl kazanç bayat girdinin olduğu
|
|
165
|
+
// durumda: upstream düşmüşken sayfayı eski veriyle ayakta tutmak,
|
|
166
|
+
// ziyaretçiye hata sayfası göstermekten iyidir.
|
|
167
|
+
const stale = read(key);
|
|
168
|
+
if (!stale) throw error;
|
|
169
|
+
|
|
170
|
+
console.warn(
|
|
171
|
+
`[data-cache] producer failed, serving stale value: ${key}`,
|
|
172
|
+
error instanceof Error ? error.message : error,
|
|
173
|
+
);
|
|
174
|
+
return /** @type {T} */ (stale.value);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* `withDataCache`'in fonksiyon sarmalayıcısı: argümanlardan anahtar üretir.
|
|
180
|
+
* `cache()` (istek içi memoizasyon) ile aynı kullanım biçimi, ama istekler
|
|
181
|
+
* arasında ve TTL'li.
|
|
182
|
+
*
|
|
183
|
+
* @param {F} fn
|
|
184
|
+
* @param {{ key: string, revalidate: number, storeEmpty?: boolean,
|
|
185
|
+
* staleFactor?: number }} options `key` önektir; argümanlar sonuna eklenir.
|
|
186
|
+
* @returns {F}
|
|
187
|
+
* @template {(...args: any[]) => Promise<any>} F
|
|
188
|
+
*/
|
|
189
|
+
export function dataCache(fn, options) {
|
|
190
|
+
const wrapped = (...args) =>
|
|
191
|
+
withDataCache(
|
|
192
|
+
args.length ? `${options.key}:${JSON.stringify(args)}` : options.key,
|
|
193
|
+
options.revalidate,
|
|
194
|
+
() => fn(...args),
|
|
195
|
+
options,
|
|
196
|
+
);
|
|
197
|
+
|
|
198
|
+
return /** @type {F} */ (wrapped);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Bir anahtarı ya da önek eşleşen tüm anahtarları düşürür. Webhook ile
|
|
203
|
+
* "bu haber güncellendi" bilgisi geldiğinde kullanılır.
|
|
204
|
+
*
|
|
205
|
+
* @param {string} [prefix] Verilmezse tüm önbellek boşaltılır.
|
|
206
|
+
* @returns {number} Silinen girdi sayısı.
|
|
207
|
+
*/
|
|
208
|
+
export function clearDataCache(prefix) {
|
|
209
|
+
if (prefix === undefined) {
|
|
210
|
+
const size = store.size;
|
|
211
|
+
store.clear();
|
|
212
|
+
return size;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
let removed = 0;
|
|
216
|
+
for (const key of store.keys()) {
|
|
217
|
+
if (key.startsWith(prefix)) {
|
|
218
|
+
store.delete(key);
|
|
219
|
+
removed += 1;
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
return removed;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/** @returns {number} */
|
|
226
|
+
export function getDataCacheSize() {
|
|
227
|
+
return store.size;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Dev raporu ve yönetim uçları için döküm. Değerin kendisi dönmez: JSON'un
|
|
232
|
+
* tamamını bir teşhis ucundan dışa vermek istenmez.
|
|
233
|
+
*
|
|
234
|
+
* @returns {{ key: string, stale: boolean, expiresIn: number }[]}
|
|
235
|
+
*/
|
|
236
|
+
export function getDataCacheEntries() {
|
|
237
|
+
const now = Date.now();
|
|
238
|
+
|
|
239
|
+
return [...store.entries()].map(([key, entry]) => ({
|
|
240
|
+
key,
|
|
241
|
+
stale: now >= entry.expiresAt,
|
|
242
|
+
expiresIn: Math.round((entry.expiresAt - now) / 1000),
|
|
243
|
+
}));
|
|
244
|
+
}
|
package/src/server/dev/report.js
CHANGED
|
@@ -13,6 +13,7 @@ import fs from "node:fs";
|
|
|
13
13
|
import path from "node:path";
|
|
14
14
|
import zlib from "node:zlib";
|
|
15
15
|
import { getHtmlCacheEntries, getHtmlCacheSize } from "../html-cache.js";
|
|
16
|
+
import { getDataCacheSize } from "../data-cache.js";
|
|
16
17
|
import { prewarmProgress } from "../prewarm.js";
|
|
17
18
|
import { getConfig } from "../../config/index.js";
|
|
18
19
|
|
|
@@ -343,7 +344,13 @@ export function buildReport(devtools) {
|
|
|
343
344
|
pages: pageList.sort((a, b) => (b.visits - a.visits) || b.at - a.at),
|
|
344
345
|
serverApi: serverApiCalls.slice().reverse(),
|
|
345
346
|
build: { assets: assets(), ...chunks() },
|
|
346
|
-
cache: {
|
|
347
|
+
cache: {
|
|
348
|
+
size: getHtmlCacheSize(),
|
|
349
|
+
entries: getHtmlCacheEntries(),
|
|
350
|
+
// Veri önbelleğinden yalnızca sayaç: uzun kuyruklu bir sitede on
|
|
351
|
+
// binlerce anahtar oluyor ve dökümü rapora koymak faydasız bir yük.
|
|
352
|
+
data: getDataCacheSize(),
|
|
353
|
+
},
|
|
347
354
|
prewarm: { ...prewarmProgress },
|
|
348
355
|
requests: devtools.requests,
|
|
349
356
|
errors: devtools.errors,
|
package/src/server/html-cache.js
CHANGED
|
@@ -8,12 +8,31 @@
|
|
|
8
8
|
* WebSocket'ten güncellendiği için bu gecikme ekranda görünmez.
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
+
import { getConfig } from "../config/index.js";
|
|
12
|
+
import { DEFAULT_HTML_CACHE_MAX_ENTRIES } from "../config/defaults.js";
|
|
13
|
+
|
|
11
14
|
/**
|
|
12
15
|
* @typedef {{ html: string, status: number, expiresAt: number,
|
|
13
16
|
* staleUntil: number, encoded: Map<string, Buffer> }} HtmlEntry
|
|
14
17
|
*/
|
|
15
18
|
|
|
16
|
-
|
|
19
|
+
/**
|
|
20
|
+
* Girdi sınırı `cache().maxEntries` ile yükseltilebilir ama uzun kuyruklu bir
|
|
21
|
+
* siteyi buradan çözmeye çalışmak yanlış katman: girdi başına yüz kilobayt
|
|
22
|
+
* düşüyor. On binlerce yol için `withDataCache` kullanılır.
|
|
23
|
+
*
|
|
24
|
+
* Config yüklenmemiş olabilir (testler bu modülü doğrudan çağırıyor); o
|
|
25
|
+
* durumda kod varsayılanı geçerli.
|
|
26
|
+
*
|
|
27
|
+
* @returns {number}
|
|
28
|
+
*/
|
|
29
|
+
function maxEntries() {
|
|
30
|
+
try {
|
|
31
|
+
return getConfig().htmlMaxEntries;
|
|
32
|
+
} catch {
|
|
33
|
+
return DEFAULT_HTML_CACHE_MAX_ENTRIES;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
17
36
|
|
|
18
37
|
/**
|
|
19
38
|
* TTL dolduktan sonra eski HTML'in kaç TTL boyunca daha servis edilebileceği.
|
|
@@ -73,7 +92,8 @@ function write(key, value, ttlSeconds) {
|
|
|
73
92
|
staleUntil: now + ttlSeconds * 1000 * (1 + STALE_FACTOR),
|
|
74
93
|
});
|
|
75
94
|
|
|
76
|
-
|
|
95
|
+
const limit = maxEntries();
|
|
96
|
+
while (store.size > limit) {
|
|
77
97
|
const oldest = store.keys().next().value;
|
|
78
98
|
if (oldest === undefined) break;
|
|
79
99
|
store.delete(oldest);
|
package/src/server/prewarm.js
CHANGED
|
@@ -11,6 +11,12 @@
|
|
|
11
11
|
* middleware zinciri normal trafikle bire bir aynı olsun. Hangi yolların
|
|
12
12
|
* ısıtılacağını uygulama `hooks.prewarmPaths()` ile bildirir; genelde
|
|
13
13
|
* sitemap üreten fonksiyonun aynısıdır.
|
|
14
|
+
*
|
|
15
|
+
* On binlerce yolluk bir sitede tur bir "damla damla" tarayıcıya dönüşür:
|
|
16
|
+
* `priority` desenleri her turda başa alınır, geri kalan kuyruk turlar
|
|
17
|
+
* arasında kaldığı yerden devam eder (`rotate`) ve `rps` toplam hızı upstream
|
|
18
|
+
* kotasının altında tutar. Amaç, kimse gelmese bile hiçbir sayfanın soğuk
|
|
19
|
+
* kalmaması — ama bunu API'yi düşürmeden yapmak.
|
|
14
20
|
*/
|
|
15
21
|
import process from "node:process";
|
|
16
22
|
import { getConfig, hook } from "../config/index.js";
|
|
@@ -78,6 +84,100 @@ async function collectPaths() {
|
|
|
78
84
|
);
|
|
79
85
|
}
|
|
80
86
|
|
|
87
|
+
/**
|
|
88
|
+
* `cache().prewarm.priority` desenlerine göre sıralar. Eşleşmeyen yollar
|
|
89
|
+
* listenin sonuna, kendi aralarındaki sırayı koruyarak gider — uygulamanın
|
|
90
|
+
* verdiği sıra hâlâ anlamlı olsun.
|
|
91
|
+
*
|
|
92
|
+
* @param {string[]} paths
|
|
93
|
+
* @returns {{ head: string[], tail: string[] }}
|
|
94
|
+
* `head` öncelikli yollar (her turda ısıtılır), `tail` geri kalan kuyruk
|
|
95
|
+
* (turlar arasında dolaşılır).
|
|
96
|
+
*/
|
|
97
|
+
function byPriority(paths) {
|
|
98
|
+
const rules = getConfig().prewarmPriority;
|
|
99
|
+
if (!rules.length) return { head: [], tail: paths };
|
|
100
|
+
|
|
101
|
+
/** @type {string[][]} */
|
|
102
|
+
const buckets = rules.map(() => []);
|
|
103
|
+
/** @type {string[]} */
|
|
104
|
+
const tail = [];
|
|
105
|
+
|
|
106
|
+
for (const candidate of paths) {
|
|
107
|
+
const rank = rules.findIndex((rule) => rule.test(candidate));
|
|
108
|
+
if (rank === -1) tail.push(candidate);
|
|
109
|
+
else buckets[rank].push(candidate);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return { head: buckets.flat(), tail };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Kuyruğun kaldığı yer. Periyodik turlar listeyi baştan ısıtıp aynı ilk
|
|
117
|
+
* `max` yolu tekrar tekrar tazelemesin: her tur bir sonraki dilimi alır ve
|
|
118
|
+
* yeterli tur sonunda liste baştan sona ısınır.
|
|
119
|
+
*/
|
|
120
|
+
let queueCursor = 0;
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Bir turda ısıtılacak dilimi seçer: önce `priority` eşleşenler, sonra
|
|
124
|
+
* kuyruğun sırası gelen parçası. Dışa açık olması bilinçli — sıralama ve
|
|
125
|
+
* rotasyon, tur çalışmadan doğrulanabilen tek davranış.
|
|
126
|
+
*
|
|
127
|
+
* @param {string[]} all
|
|
128
|
+
* @param {number} limit
|
|
129
|
+
* @param {boolean} rotate
|
|
130
|
+
* @returns {string[]}
|
|
131
|
+
*/
|
|
132
|
+
export function selectPrewarmPaths(all, limit, rotate = true) {
|
|
133
|
+
if (all.length <= limit) return all;
|
|
134
|
+
|
|
135
|
+
const { head, tail } = byPriority(all);
|
|
136
|
+
const selected = head.slice(0, limit);
|
|
137
|
+
const room = limit - selected.length;
|
|
138
|
+
if (room <= 0 || !tail.length) return selected;
|
|
139
|
+
|
|
140
|
+
if (!rotate) return [...selected, ...tail.slice(0, room)];
|
|
141
|
+
|
|
142
|
+
// Dilim listenin sonunu aşarsa başa sarar: kuyruk halkasal dolaşılır.
|
|
143
|
+
const start = queueCursor % tail.length;
|
|
144
|
+
const slice = tail.slice(start, start + room);
|
|
145
|
+
if (slice.length < room) slice.push(...tail.slice(0, room - slice.length));
|
|
146
|
+
queueCursor = (start + room) % tail.length;
|
|
147
|
+
|
|
148
|
+
return [...selected, ...slice];
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** @param {number} ms */
|
|
152
|
+
function sleep(ms) {
|
|
153
|
+
return new Promise((resolve) => {
|
|
154
|
+
setTimeout(resolve, ms).unref?.();
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Saniyedeki istek sayısını sınırlar. Fren `concurrency`'den bağımsız
|
|
160
|
+
* olmalı: paralellik gecikmeyi kapatmak için var, kotayı koruyan şey toplam
|
|
161
|
+
* hız. İşçiler aynı sayacı paylaştığı için sıra kimde olursa olsun tur
|
|
162
|
+
* verilen hızın üstüne çıkmaz.
|
|
163
|
+
*
|
|
164
|
+
* @param {number} rps 0 → sınırsız
|
|
165
|
+
* @returns {() => Promise<void>}
|
|
166
|
+
*/
|
|
167
|
+
function createPacer(rps) {
|
|
168
|
+
if (!rps) return async () => {};
|
|
169
|
+
|
|
170
|
+
const gap = 1000 / rps;
|
|
171
|
+
let nextSlot = 0;
|
|
172
|
+
|
|
173
|
+
return async () => {
|
|
174
|
+
const now = Date.now();
|
|
175
|
+
const slot = Math.max(now, nextSlot);
|
|
176
|
+
nextSlot = slot + gap;
|
|
177
|
+
if (slot > now) await sleep(slot - now);
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
|
|
81
181
|
/**
|
|
82
182
|
* `DEV_TOKEN` ayarlıyken `devGate` token taşımayan her isteğe 404 döner.
|
|
83
183
|
* Isıtma kendi sunucusuna istek attığı için token'ı çerez olarak taşımalı;
|
|
@@ -100,9 +200,10 @@ function devGateHeader() {
|
|
|
100
200
|
* @param {(ok: number, failed: number) => void} [report]
|
|
101
201
|
* Tur ilerlemesini `prewarmProgress`'e yazar. Tekrar turunda sayaçların
|
|
102
202
|
* anlamı değiştiği için çağıran taraf kendi formülünü verir.
|
|
203
|
+
* @param {() => Promise<void>} [pace] İstek başına beklenen hız freni.
|
|
103
204
|
* @returns {Promise<{ ok: number, failed: number, failedPaths: string[] }>}
|
|
104
205
|
*/
|
|
105
|
-
async function crawl(origin, paths, concurrency, report = undefined) {
|
|
206
|
+
async function crawl(origin, paths, concurrency, report = undefined, pace = undefined) {
|
|
106
207
|
const { brand } = getConfig();
|
|
107
208
|
const cacheHeader = brand.cacheHeader.toLowerCase();
|
|
108
209
|
|
|
@@ -117,6 +218,8 @@ async function crawl(origin, paths, concurrency, report = undefined) {
|
|
|
117
218
|
const target = paths[index];
|
|
118
219
|
index += 1;
|
|
119
220
|
|
|
221
|
+
if (pace) await pace();
|
|
222
|
+
|
|
120
223
|
const startedAt = Date.now();
|
|
121
224
|
|
|
122
225
|
try {
|
|
@@ -191,8 +294,15 @@ export async function prewarm({ origin, quiet = false, paths: only }) {
|
|
|
191
294
|
process.env.NODE_ENV === "development" ? 2 : 4,
|
|
192
295
|
);
|
|
193
296
|
|
|
297
|
+
const rps = num(process.env.PREWARM_RPS, num(getConfig().prewarm?.rps, 0));
|
|
298
|
+
const pace = createPacer(rps);
|
|
299
|
+
|
|
194
300
|
const all = only?.length ? only : await collectPaths();
|
|
195
|
-
|
|
301
|
+
// Elle verilen liste budanmaz ve sıralanmaz: çağıran tam olarak neyi
|
|
302
|
+
// istediğini biliyor (dev panelindeki "tekrar dene" bunu kullanır).
|
|
303
|
+
const paths = only?.length
|
|
304
|
+
? all
|
|
305
|
+
: selectPrewarmPaths(all, limit, getConfig().prewarm?.rotate !== false);
|
|
196
306
|
|
|
197
307
|
Object.assign(prewarmProgress, {
|
|
198
308
|
active: true,
|
|
@@ -211,7 +321,13 @@ export async function prewarm({ origin, quiet = false, paths: only }) {
|
|
|
211
321
|
try {
|
|
212
322
|
/** @type {string[]} */
|
|
213
323
|
let failedPaths;
|
|
214
|
-
({ ok, failed, failedPaths } = await crawl(
|
|
324
|
+
({ ok, failed, failedPaths } = await crawl(
|
|
325
|
+
origin,
|
|
326
|
+
paths,
|
|
327
|
+
concurrency,
|
|
328
|
+
undefined,
|
|
329
|
+
pace,
|
|
330
|
+
));
|
|
215
331
|
|
|
216
332
|
// Hatalar çoğunlukla upstream rate limit'i (429): ilk tur yüzlerce sayfayı
|
|
217
333
|
// aynı anda çekerken API'yi zorluyor. Tek seri tekrar turu bu sayfaların
|
|
@@ -219,11 +335,23 @@ export async function prewarm({ origin, quiet = false, paths: only }) {
|
|
|
219
335
|
if (failedPaths.length) {
|
|
220
336
|
const firstOk = ok;
|
|
221
337
|
const firstFailed = failed;
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
338
|
+
|
|
339
|
+
// Rate limit pencereleri saniye mertebesinde; hemen tekrar denemek aynı
|
|
340
|
+
// 429'u almak demek.
|
|
341
|
+
const retryDelay = setting("PREWARM_RETRY_DELAY_MS", "retryDelayMs", 2000);
|
|
342
|
+
await sleep(retryDelay);
|
|
343
|
+
|
|
344
|
+
const retry = await crawl(
|
|
345
|
+
origin,
|
|
346
|
+
failedPaths,
|
|
347
|
+
1,
|
|
348
|
+
(retriedOk) => {
|
|
349
|
+
// Tekrar turunda her başarı bir hatayı başarıya çevirir.
|
|
350
|
+
prewarmProgress.ok = firstOk + retriedOk;
|
|
351
|
+
prewarmProgress.failed = firstFailed - retriedOk;
|
|
352
|
+
},
|
|
353
|
+
pace,
|
|
354
|
+
);
|
|
227
355
|
recovered = retry.ok;
|
|
228
356
|
ok += retry.ok;
|
|
229
357
|
failed -= retry.ok;
|
|
@@ -236,11 +364,15 @@ export async function prewarm({ origin, quiet = false, paths: only }) {
|
|
|
236
364
|
|
|
237
365
|
if (!quiet && paths.length) {
|
|
238
366
|
const skipped = all.length - paths.length;
|
|
367
|
+
// Rotasyon açıkken sınırın dışında kalan yollar kaybolmuyor, bir sonraki
|
|
368
|
+
// tura kalıyor; log bunu ayırt etmeli, yoksa "400 yol atlandı" satırı
|
|
369
|
+
// hatalı bir kurulum sanılıyor.
|
|
370
|
+
const rotate = !only?.length && getConfig().prewarm?.rotate !== false;
|
|
239
371
|
console.log(
|
|
240
372
|
`[prewarm] warmed ${ok}/${paths.length} pages` +
|
|
241
373
|
`${failed ? `, ${failed} failed` : ""}` +
|
|
242
374
|
`${recovered ? `, ${recovered} recovered on the retry pass` : ""}` +
|
|
243
|
-
`${skipped > 0 ? `, ${skipped} over the limit` : ""}` +
|
|
375
|
+
`${skipped > 0 ? `, ${skipped} ${rotate ? "deferred to the next pass" : "over the limit"}` : ""}` +
|
|
244
376
|
` (${(elapsed / 1000).toFixed(1)}s)`,
|
|
245
377
|
);
|
|
246
378
|
}
|
|
@@ -264,20 +396,33 @@ export function startPrewarm({ port }) {
|
|
|
264
396
|
|
|
265
397
|
const isDev = process.env.NODE_ENV === "development";
|
|
266
398
|
const origin = `http://127.0.0.1:${port}`;
|
|
267
|
-
|
|
268
|
-
|
|
399
|
+
|
|
400
|
+
// Hız frenli bir tur `intervalSeconds`'tan uzun sürebilir; üst üste binen
|
|
401
|
+
// turlar `prewarmProgress`'i bozar ve upstream'e iki kat yük bindirir.
|
|
402
|
+
let running = false;
|
|
403
|
+
const run = async () => {
|
|
404
|
+
if (running) return;
|
|
405
|
+
running = true;
|
|
406
|
+
try {
|
|
407
|
+
await prewarm({ origin });
|
|
408
|
+
} catch (error) {
|
|
269
409
|
console.error("[prewarm] failed", error);
|
|
270
|
-
}
|
|
410
|
+
} finally {
|
|
411
|
+
running = false;
|
|
412
|
+
}
|
|
413
|
+
};
|
|
271
414
|
|
|
272
415
|
// Isıtma ilk isteklerle yarışmasın diye gecikmeyle başlar. Dev'de gecikme
|
|
273
416
|
// daha uzun: dosya kaydı süreci yeniden başlattığı için zamanlayıcı da
|
|
274
417
|
// ölür; yalnızca sunucu bir süre sakin kalınca ısınır.
|
|
275
418
|
const delay = setting("PREWARM_DELAY_MS", "delayMs", isDev ? 3000 : 500);
|
|
276
|
-
setTimeout(run, delay).unref();
|
|
419
|
+
setTimeout(() => void run(), delay).unref();
|
|
277
420
|
|
|
278
421
|
// Girdiler `revalidate` ile yaşlanır; stale-while-revalidate sayesinde
|
|
279
422
|
// ziyaretçi beklemez. Periyodik tur, hiç ziyaret edilmeyen sayfaları da
|
|
280
423
|
// sıcak tutmak isteyen kurulumlar için opsiyoneldir.
|
|
424
|
+
// `rotate` ile birlikte bu ayar "damla damla ısıtma"ya dönüşür: her tur
|
|
425
|
+
// kuyruğun bir dilimini alır, yeterli tur sonunda liste baştan sona ısınır.
|
|
281
426
|
const interval = setting("PREWARM_INTERVAL_SECONDS", "intervalSeconds", 0);
|
|
282
|
-
if (interval > 0) setInterval(run, interval * 1000).unref();
|
|
427
|
+
if (interval > 0) setInterval(() => void run(), interval * 1000).unref();
|
|
283
428
|
}
|
package/src/server/render.js
CHANGED
|
@@ -226,20 +226,35 @@ export function route(controller, options = {}) {
|
|
|
226
226
|
);
|
|
227
227
|
}
|
|
228
228
|
|
|
229
|
-
|
|
229
|
+
// Eksik veriyle üretilen çıktı süreç içi önbelleğe yazılmıyor; aynı
|
|
230
|
+
// çıktıya CDN'de `s-maxage` vermek o kararı bir katman yukarıda geri
|
|
231
|
+
// almak olurdu. Geçici bir 429 yüzünden üretilen 503, ters proxy'de
|
|
232
|
+
// dakikalarca yaşamamalı.
|
|
233
|
+
const publicCache = cacheable && !leaked && !result.degraded;
|
|
230
234
|
|
|
231
235
|
res.status(result.status);
|
|
232
236
|
res.setHeader("Content-Type", "text/html; charset=utf-8");
|
|
233
237
|
|
|
238
|
+
// Geçici upstream hatası: istemciye ve bota "bu kalıcı değil, sonra gel"
|
|
239
|
+
// demenin standart yolu.
|
|
240
|
+
if (result.retryAfter) {
|
|
241
|
+
res.setHeader("Retry-After", String(result.retryAfter));
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// Teşhis başlığı `degraded` yanıtta da yazılır: "MISS" görmek, sayfanın
|
|
245
|
+
// önbellek yolundan geçtiğini ama saklanmadığını anlatan tek ipucu.
|
|
246
|
+
if (cacheable && !leaked) {
|
|
247
|
+
res.setHeader(
|
|
248
|
+
getConfig().brand.cacheHeader,
|
|
249
|
+
result.cached ? (result.stale ? "STALE" : "HIT") : "MISS",
|
|
250
|
+
);
|
|
251
|
+
}
|
|
252
|
+
|
|
234
253
|
if (publicCache) {
|
|
235
254
|
res.setHeader(
|
|
236
255
|
"Cache-Control",
|
|
237
256
|
`public, max-age=0, s-maxage=${revalidate}, stale-while-revalidate=60`,
|
|
238
257
|
);
|
|
239
|
-
res.setHeader(
|
|
240
|
-
getConfig().brand.cacheHeader,
|
|
241
|
-
result.cached ? (result.stale ? "STALE" : "HIT") : "MISS",
|
|
242
|
-
);
|
|
243
258
|
} else {
|
|
244
259
|
res.setHeader("Cache-Control", PRIVATE_CACHE);
|
|
245
260
|
// Anahtarında cookie olmayan bir cache'in bu yanıtı paylaşmasını
|
|
@@ -459,7 +474,7 @@ async function sendHtml(req, res, body, encoded, options = {}) {
|
|
|
459
474
|
* @param {Function} controller
|
|
460
475
|
* @param {{ pathname: string }} ctx
|
|
461
476
|
* @returns {Promise<{ html: string, status: number, degraded?: boolean,
|
|
462
|
-
* storable?: boolean }>}
|
|
477
|
+
* storable?: boolean, retryAfter?: number }>}
|
|
463
478
|
*/
|
|
464
479
|
async function produce(controller, ctx) {
|
|
465
480
|
try {
|
|
@@ -475,12 +490,39 @@ async function produce(controller, ctx) {
|
|
|
475
490
|
};
|
|
476
491
|
} catch (error) {
|
|
477
492
|
if (isNotFoundError(error)) {
|
|
493
|
+
// Veri gelmediği için `notFound()` çağrılmış olabilir: geçici bir
|
|
494
|
+
// upstream hatası (429, 5xx, ağ) varken bunu 404 olarak servis etmek iki
|
|
495
|
+
// kere yanlış. Önbelleğe girip TTL boyunca "bu sayfa yok" cevabını
|
|
496
|
+
// sabitler ve arama motoru geçici bir rate limit'i kalıcı 404 sanar.
|
|
497
|
+
// Doğrusu 503: cache'lenmez, `Retry-After` ile gider, sonraki istek
|
|
498
|
+
// gerçek içeriği üretir.
|
|
499
|
+
const transient = transientUpstreamFailures();
|
|
500
|
+
if (transient.length) {
|
|
501
|
+
console.warn(
|
|
502
|
+
`[render] ${ctx.pathname} returned notFound() while upstream is failing ` +
|
|
503
|
+
`(${summarizeFailures(transient)}), serving an uncached 503 instead`,
|
|
504
|
+
);
|
|
505
|
+
return {
|
|
506
|
+
html: await renderStatusPage(503),
|
|
507
|
+
status: 503,
|
|
508
|
+
degraded: true,
|
|
509
|
+
retryAfter: RETRY_AFTER_SECONDS,
|
|
510
|
+
};
|
|
511
|
+
}
|
|
512
|
+
|
|
478
513
|
return { html: await renderNotFound(), status: 404 };
|
|
479
514
|
}
|
|
480
515
|
throw error;
|
|
481
516
|
}
|
|
482
517
|
}
|
|
483
518
|
|
|
519
|
+
/**
|
|
520
|
+
* Geçici bir upstream hatası yüzünden üretilemeyen sayfanın `Retry-After`
|
|
521
|
+
* değeri. Kısa tutuluyor: ziyaretçi de bot da birkaç saniye sonra gerçek
|
|
522
|
+
* içeriği bulabilsin.
|
|
523
|
+
*/
|
|
524
|
+
const RETRY_AFTER_SECONDS = 30;
|
|
525
|
+
|
|
484
526
|
/** Ağ hatası (0) ve geçici olduğu varsayılan durumlar. */
|
|
485
527
|
const TRANSIENT_STATUSES = new Set([0, 408, 425, 429]);
|
|
486
528
|
|
|
@@ -506,28 +548,39 @@ function hasUpstreamFailures(pathname) {
|
|
|
506
548
|
const failures = getUpstreamFailures();
|
|
507
549
|
if (!failures.length) return false;
|
|
508
550
|
|
|
509
|
-
/** @param {typeof failures} list */
|
|
510
|
-
const summarize = (list) =>
|
|
511
|
-
list.map((failure) => `${failure.status} ${failure.path}`).join(", ");
|
|
512
|
-
|
|
513
551
|
const transient = failures.filter((failure) => isTransient(failure.status));
|
|
514
552
|
const permanent = failures.filter((failure) => !isTransient(failure.status));
|
|
515
553
|
|
|
516
554
|
if (permanent.length) {
|
|
517
555
|
console.warn(
|
|
518
|
-
`[render] ${pathname} was produced with missing data, upstream is failing permanently (${
|
|
556
|
+
`[render] ${pathname} was produced with missing data, upstream is failing permanently (${summarizeFailures(permanent)})`,
|
|
519
557
|
);
|
|
520
558
|
}
|
|
521
559
|
|
|
522
560
|
if (!transient.length) return false;
|
|
523
561
|
|
|
524
562
|
console.warn(
|
|
525
|
-
`[render] ${pathname} was produced with missing data, not caching it (${
|
|
563
|
+
`[render] ${pathname} was produced with missing data, not caching it (${summarizeFailures(transient)})`,
|
|
526
564
|
);
|
|
527
565
|
|
|
528
566
|
return true;
|
|
529
567
|
}
|
|
530
568
|
|
|
569
|
+
/**
|
|
570
|
+
* @returns {import('./upstream-tracking.js').UpstreamFailure[]}
|
|
571
|
+
*/
|
|
572
|
+
function transientUpstreamFailures() {
|
|
573
|
+
return getUpstreamFailures().filter((failure) => isTransient(failure.status));
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
/**
|
|
577
|
+
* @param {import('./upstream-tracking.js').UpstreamFailure[]} failures
|
|
578
|
+
* @returns {string}
|
|
579
|
+
*/
|
|
580
|
+
function summarizeFailures(failures) {
|
|
581
|
+
return failures.map((failure) => `${failure.status} ${failure.path}`).join(", ");
|
|
582
|
+
}
|
|
583
|
+
|
|
531
584
|
/**
|
|
532
585
|
* 404 sayfası. `renderStatusPage(404)` için kısayol; route dosyalarında en sık
|
|
533
586
|
* ihtiyaç duyulan durum bu olduğu için ayrı bir ad taşımaya devam ediyor.
|