jskelet 0.2.1 → 0.2.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +368 -261
- package/docs/03-routing.md +4 -0
- package/docs/06-cache.md +340 -12
- package/docs/07-yapilandirma.md +131 -2
- package/docs/en/03-routing.md +4 -0
- package/docs/en/06-caching.md +347 -14
- package/docs/en/07-configuration.md +135 -2
- package/package.json +1 -1
- package/src/client/cache-panel/login.html +71 -0
- package/src/client/cache-panel/panel.css +740 -0
- package/src/client/cache-panel/panel.html +307 -0
- package/src/client/cache-panel/panel.js +808 -0
- package/src/client/devtools/overlay.js +85 -0
- package/src/client/devtools/report.js +13 -0
- package/src/config/defaults.js +98 -2
- package/src/config/index.js +185 -3
- package/src/index.js +21 -1
- package/src/log.mjs +25 -0
- package/src/server/cache-panel.js +738 -0
- package/src/server/cloudflare.js +595 -0
- package/src/server/create-app.js +16 -0
- package/src/server/data-cache.js +92 -2
- package/src/server/dev/report.js +8 -1
- package/src/server/html-cache.js +36 -0
- package/src/server/prewarm.js +94 -15
- package/src/server/redis.js +108 -0
- package/src/server/render.js +74 -2
- package/src/server/upstream-limiter.js +376 -0
- package/src/server/upstream-tracking.js +25 -0
- package/src/version.mjs +9 -0
package/src/server/data-cache.js
CHANGED
|
@@ -47,6 +47,31 @@ const store = new Map();
|
|
|
47
47
|
/** @type {Map<string, Promise<unknown>>} */
|
|
48
48
|
const inflight = new Map();
|
|
49
49
|
|
|
50
|
+
/**
|
|
51
|
+
* Süreç ömrü boyunca biriken sayaçlar.
|
|
52
|
+
*
|
|
53
|
+
* Isıtma turunun kotayı ne kadar harcadığı ancak buradan görülüyor: tur
|
|
54
|
+
* bittiğinde `produced` kaç gerçek upstream çağrısı yapıldığını, `hits` kaçının
|
|
55
|
+
* hiç gitmediğini söyler. Oran düşükse çözüm hız freni değil, TTL'i uzatmak —
|
|
56
|
+
* fren çağrıları yavaşlatır, sayısını azaltmaz.
|
|
57
|
+
*/
|
|
58
|
+
const stats = {
|
|
59
|
+
/** Taze girdiden servis edildi. */
|
|
60
|
+
hits: 0,
|
|
61
|
+
/** Bayat girdiden servis edildi; tazeleme arkada koştu. */
|
|
62
|
+
stale: 0,
|
|
63
|
+
/** Girdi yoktu, çağıran bekledi. */
|
|
64
|
+
misses: 0,
|
|
65
|
+
/** Aynı anahtarı eşzamanlı isteyen çağrılar tek üretime düştü. */
|
|
66
|
+
coalesced: 0,
|
|
67
|
+
/** Paylaşımlı kademeden geldi; upstream'e gitmedi. */
|
|
68
|
+
shared: 0,
|
|
69
|
+
/** `producer` gerçekten çalıştı — kotaya yazılan tek sayı. */
|
|
70
|
+
produced: 0,
|
|
71
|
+
/** `ttlSeconds: 0` ile önbellek tamamen atlandı. */
|
|
72
|
+
bypassed: 0,
|
|
73
|
+
};
|
|
74
|
+
|
|
50
75
|
/**
|
|
51
76
|
* Ayarlar config'ten okunur ama config yüklenmemiş olabilir: bu modül
|
|
52
77
|
* script'lerden ve testlerden de çağrılabiliyor. `getConfig()` fırlatırsa
|
|
@@ -171,7 +196,10 @@ function refresh(key, ttlSeconds, producer, options) {
|
|
|
171
196
|
// Aynı anahtarı eşzamanlı isteyen yüz sayfa tek upstream isteğine düşer.
|
|
172
197
|
// Isıtma turlarında bu tek başına kotanın büyük kısmını kurtarıyor.
|
|
173
198
|
const pending = inflight.get(key);
|
|
174
|
-
if (pending)
|
|
199
|
+
if (pending) {
|
|
200
|
+
stats.coalesced += 1;
|
|
201
|
+
return pending;
|
|
202
|
+
}
|
|
175
203
|
|
|
176
204
|
const staleFactor = options.staleFactor ?? settings().staleFactor;
|
|
177
205
|
|
|
@@ -196,10 +224,12 @@ async function produce(key, ttlSeconds, producer, options, staleFactor) {
|
|
|
196
224
|
// Kotayı koruyan `inflight` birleştirmesinin küme çapındaki karşılığı bu.
|
|
197
225
|
const shared = await readShared(key);
|
|
198
226
|
if (shared) {
|
|
227
|
+
stats.shared += 1;
|
|
199
228
|
promote(key, shared);
|
|
200
229
|
return shared.value;
|
|
201
230
|
}
|
|
202
231
|
|
|
232
|
+
stats.produced += 1;
|
|
203
233
|
const value = await producer();
|
|
204
234
|
|
|
205
235
|
const empty = value === undefined || value === null;
|
|
@@ -224,7 +254,10 @@ async function produce(key, ttlSeconds, producer, options, staleFactor) {
|
|
|
224
254
|
* @template T
|
|
225
255
|
*/
|
|
226
256
|
export async function withDataCache(key, ttlSeconds, producer, options = {}) {
|
|
227
|
-
if (!ttlSeconds)
|
|
257
|
+
if (!ttlSeconds) {
|
|
258
|
+
stats.bypassed += 1;
|
|
259
|
+
return producer();
|
|
260
|
+
}
|
|
228
261
|
|
|
229
262
|
// Bu anahtarı okuyan render, `clearDataCache(key)` çağrıldığında etkilenen
|
|
230
263
|
// sayfalar arasında sayılsın. Render bağlamı yoksa çağrı no-op.
|
|
@@ -233,6 +266,9 @@ export async function withDataCache(key, ttlSeconds, producer, options = {}) {
|
|
|
233
266
|
const hit = read(key);
|
|
234
267
|
|
|
235
268
|
if (hit) {
|
|
269
|
+
if (hit.stale) stats.stale += 1;
|
|
270
|
+
else stats.hits += 1;
|
|
271
|
+
|
|
236
272
|
// Bayat girdi anında döner; tazeleme arkada yürür ve hatası bu isteği
|
|
237
273
|
// etkilemez — çağıran taraf bir şey beklemediği için upstream'in yavaş
|
|
238
274
|
// olması sayfaya yansımaz.
|
|
@@ -244,6 +280,8 @@ export async function withDataCache(key, ttlSeconds, producer, options = {}) {
|
|
|
244
280
|
return /** @type {T} */ (hit.value);
|
|
245
281
|
}
|
|
246
282
|
|
|
283
|
+
stats.misses += 1;
|
|
284
|
+
|
|
247
285
|
try {
|
|
248
286
|
return /** @type {T} */ (await refresh(key, ttlSeconds, producer, options));
|
|
249
287
|
} catch (error) {
|
|
@@ -346,15 +384,67 @@ function clearLocal(prefix) {
|
|
|
346
384
|
// Uzak bir node veri düşürdüğünde bu proses de kendi L1'ini temizler; zincir
|
|
347
385
|
// `invalidateHtmlByDependency` üzerinden etkilenen sayfalara kadar gider.
|
|
348
386
|
onCacheEvent((event) => {
|
|
387
|
+
if (event.type === "data:drop") {
|
|
388
|
+
if (typeof event.key !== "string") return;
|
|
389
|
+
store.delete(event.key);
|
|
390
|
+
invalidateHtmlByDependency([event.key]);
|
|
391
|
+
return;
|
|
392
|
+
}
|
|
393
|
+
|
|
349
394
|
if (event.type !== "data:clear") return;
|
|
350
395
|
clearLocal(typeof event.prefix === "string" ? event.prefix : undefined);
|
|
351
396
|
});
|
|
352
397
|
|
|
398
|
+
/**
|
|
399
|
+
* Tek bir veri anahtarını düşürür.
|
|
400
|
+
*
|
|
401
|
+
* `clearDataCache()` **önek** eşleştiriyor: `quote:v2:AAPL` verildiğinde
|
|
402
|
+
* `quote:v2:AAPLX` de düşer. Yönetim panelinde listeden seçilen satır tam
|
|
403
|
+
* olarak o anahtar olmalı, komşusu değil.
|
|
404
|
+
*
|
|
405
|
+
* @param {string} key
|
|
406
|
+
* @returns {boolean} Girdi var mıydı.
|
|
407
|
+
*/
|
|
408
|
+
export function dropDataCacheKey(key) {
|
|
409
|
+
const existed = store.delete(key);
|
|
410
|
+
|
|
411
|
+
// Silme, girdi bu node'da olmasa da yayılır: anahtar başka bir node'da ya da
|
|
412
|
+
// yalnızca Redis'te sıcak olabilir.
|
|
413
|
+
invalidateHtmlByDependency([key]);
|
|
414
|
+
|
|
415
|
+
if (redisShares("data")) {
|
|
416
|
+
void redisDropMatching("data", (candidate) => candidate === key);
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
publishCacheEvent({ type: "data:drop", key });
|
|
420
|
+
|
|
421
|
+
return existed;
|
|
422
|
+
}
|
|
423
|
+
|
|
353
424
|
/** @returns {number} */
|
|
354
425
|
export function getDataCacheSize() {
|
|
355
426
|
return store.size;
|
|
356
427
|
}
|
|
357
428
|
|
|
429
|
+
/**
|
|
430
|
+
* Süreç başından beri biriken sayaçlar. `produced` kotaya yazılan tek sayıdır:
|
|
431
|
+
* geri kalan her şey upstream'e hiç gitmemiş bir okuma.
|
|
432
|
+
*
|
|
433
|
+
* @returns {typeof stats & { reads: number, hitRatio: number }}
|
|
434
|
+
* `reads` önbellekten geçen toplam okuma, `hitRatio` bunların kaçının
|
|
435
|
+
* upstream'e gitmediği (0–1).
|
|
436
|
+
*/
|
|
437
|
+
export function getDataCacheStats() {
|
|
438
|
+
const reads = stats.hits + stats.stale + stats.misses;
|
|
439
|
+
const avoided = stats.hits + stats.stale;
|
|
440
|
+
|
|
441
|
+
return {
|
|
442
|
+
...stats,
|
|
443
|
+
reads,
|
|
444
|
+
hitRatio: reads ? Number((avoided / reads).toFixed(3)) : 0,
|
|
445
|
+
};
|
|
446
|
+
}
|
|
447
|
+
|
|
358
448
|
/**
|
|
359
449
|
* Dev raporu ve yönetim uçları için döküm. Değerin kendisi dönmez: JSON'un
|
|
360
450
|
* tamamını bir teşhis ucundan dışa vermek istenmez.
|
package/src/server/dev/report.js
CHANGED
|
@@ -13,8 +13,9 @@ 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
|
+
import { getDataCacheSize, getDataCacheStats } from "../data-cache.js";
|
|
17
17
|
import { getRedisStatus } from "../redis.js";
|
|
18
|
+
import { getUpstreamLimiterStatus } from "../upstream-limiter.js";
|
|
18
19
|
import { prewarmProgress } from "../prewarm.js";
|
|
19
20
|
import { getConfig } from "../../config/index.js";
|
|
20
21
|
|
|
@@ -351,10 +352,16 @@ export function buildReport(devtools) {
|
|
|
351
352
|
// Veri önbelleğinden yalnızca sayaç: uzun kuyruklu bir sitede on
|
|
352
353
|
// binlerce anahtar oluyor ve dökümü rapora koymak faydasız bir yük.
|
|
353
354
|
data: getDataCacheSize(),
|
|
355
|
+
// Sayaçlar dökümün yerine geçmiyor, onu tamamlıyor: "kaç girdi var"
|
|
356
|
+
// sorusundan çok "kaç okuma upstream'e gitti" sorusu karar veriyor.
|
|
357
|
+
dataStats: getDataCacheStats(),
|
|
354
358
|
// Paylaşımlı kademe kapalıyken de basılır: "Redis'i açtım ama neden
|
|
355
359
|
// çalışmıyor" sorusunun cevabı en çok burada aranıyor.
|
|
356
360
|
redis: getRedisStatus(),
|
|
357
361
|
},
|
|
362
|
+
// Hız freninin o anki durumu. 429 fırtınasında "şu an saniyede kaça
|
|
363
|
+
// indi" bilgisi olmadan ayar yapmak körlemesine oluyor.
|
|
364
|
+
upstream: getUpstreamLimiterStatus(),
|
|
358
365
|
prewarm: { ...prewarmProgress },
|
|
359
366
|
requests: devtools.requests,
|
|
360
367
|
errors: devtools.errors,
|
package/src/server/html-cache.js
CHANGED
|
@@ -686,6 +686,11 @@ onCacheEvent((event) => {
|
|
|
686
686
|
return;
|
|
687
687
|
}
|
|
688
688
|
|
|
689
|
+
if (event.type === "html:drop") {
|
|
690
|
+
if (typeof event.key === "string") dropLocalKey(event.key);
|
|
691
|
+
return;
|
|
692
|
+
}
|
|
693
|
+
|
|
689
694
|
if (event.type !== "html:invalidate") return;
|
|
690
695
|
|
|
691
696
|
const targets = /** @type {(string | RegExp)[]} */ (
|
|
@@ -742,6 +747,37 @@ export function invalidateHtmlByDependency(dataKeys) {
|
|
|
742
747
|
return keys.size;
|
|
743
748
|
}
|
|
744
749
|
|
|
750
|
+
/**
|
|
751
|
+
* Tek bir önbellek **anahtarını** düşürür.
|
|
752
|
+
*
|
|
753
|
+
* `invalidateHtmlCache()` yol deseniyle çalışıyor ve bir yolun bütün query
|
|
754
|
+
* varyantlarını birlikte düşürüyor. Yönetim paneli listedeki tek satırı
|
|
755
|
+
* silebilmek istiyor: `/liste?sayfa=2` düşerken `/liste?sayfa=3` sıcak
|
|
756
|
+
* kalmalı. Desen sözdiziminde `?` kaçırılamadığı için ayrı bir yüzey.
|
|
757
|
+
*
|
|
758
|
+
* @param {string} key `yol?query` biçiminde tam anahtar.
|
|
759
|
+
* @returns {boolean} Girdi var mıydı.
|
|
760
|
+
*/
|
|
761
|
+
export function dropHtmlCacheKey(key) {
|
|
762
|
+
const existed = dropLocalKey(key);
|
|
763
|
+
|
|
764
|
+
if (redisShares("html")) redisDrop([cacheKey("html", key)]);
|
|
765
|
+
publishCacheEvent({ type: "html:drop", key });
|
|
766
|
+
|
|
767
|
+
return existed;
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
/**
|
|
771
|
+
* @param {string} key
|
|
772
|
+
* @returns {boolean}
|
|
773
|
+
*/
|
|
774
|
+
function dropLocalKey(key) {
|
|
775
|
+
// Uçuştaki tazeleme de geçersiz: silinen girdiyi geri yazmamalı.
|
|
776
|
+
tokens.delete(key);
|
|
777
|
+
invalidated.delete(key);
|
|
778
|
+
return drop(key);
|
|
779
|
+
}
|
|
780
|
+
|
|
745
781
|
/**
|
|
746
782
|
* Invalidate edilmiş ve henüz kimsenin istemediği yolları döner ve kuyruğu
|
|
747
783
|
* boşaltır. Isıtma turu bunları başa alır; iki tur aynı yolu tekrar
|
package/src/server/prewarm.js
CHANGED
|
@@ -22,6 +22,9 @@ import process from "node:process";
|
|
|
22
22
|
import { getConfig, hook } from "../config/index.js";
|
|
23
23
|
import { getRequestContext } from "../http/request-context.js";
|
|
24
24
|
import { takeInvalidatedPaths } from "./html-cache.js";
|
|
25
|
+
import { getDataCacheStats } from "./data-cache.js";
|
|
26
|
+
import { isTransientStatus } from "./upstream-tracking.js";
|
|
27
|
+
import { upstreamCooldownMs } from "./upstream-limiter.js";
|
|
25
28
|
|
|
26
29
|
/**
|
|
27
30
|
* Isıtmanın canlı durumu. Dev araçları bunu okuyup ilerlemeyi gösterir;
|
|
@@ -266,7 +269,10 @@ function devGateHeader() {
|
|
|
266
269
|
* Tur ilerlemesini `prewarmProgress`'e yazar. Tekrar turunda sayaçların
|
|
267
270
|
* anlamı değiştiği için çağıran taraf kendi formülünü verir.
|
|
268
271
|
* @param {() => Promise<void>} [pace] İstek başına beklenen hız freni.
|
|
269
|
-
* @returns {Promise<{ ok: number, failed: number,
|
|
272
|
+
* @returns {Promise<{ ok: number, failed: number,
|
|
273
|
+
* failures: { path: string, status: number }[] }>}
|
|
274
|
+
* `failures` durum koduyla birlikte döner: tekrar turuna yalnızca geçici
|
|
275
|
+
* hatalar alınıyor, kalıcı olanı yeniden denemek boşa çağrı.
|
|
270
276
|
*/
|
|
271
277
|
async function crawl(origin, paths, concurrency, report = undefined, pace = undefined) {
|
|
272
278
|
const { brand } = getConfig();
|
|
@@ -275,8 +281,8 @@ async function crawl(origin, paths, concurrency, report = undefined, pace = unde
|
|
|
275
281
|
let index = 0;
|
|
276
282
|
let ok = 0;
|
|
277
283
|
let failed = 0;
|
|
278
|
-
/** @type {string[]} */
|
|
279
|
-
const
|
|
284
|
+
/** @type {{ path: string, status: number }[]} */
|
|
285
|
+
const failures = [];
|
|
280
286
|
|
|
281
287
|
async function worker() {
|
|
282
288
|
while (index < paths.length) {
|
|
@@ -301,7 +307,7 @@ async function crawl(origin, paths, concurrency, report = undefined, pace = unde
|
|
|
301
307
|
if (response.ok) ok += 1;
|
|
302
308
|
else {
|
|
303
309
|
failed += 1;
|
|
304
|
-
|
|
310
|
+
failures.push({ path: target, status: response.status });
|
|
305
311
|
}
|
|
306
312
|
|
|
307
313
|
prewarmProgress.entries.push({
|
|
@@ -314,7 +320,8 @@ async function crawl(origin, paths, concurrency, report = undefined, pace = unde
|
|
|
314
320
|
});
|
|
315
321
|
} catch (error) {
|
|
316
322
|
failed += 1;
|
|
317
|
-
|
|
323
|
+
// Yanıt hiç gelmedi: ağ hatası her zaman geçici, tekrar denenir.
|
|
324
|
+
failures.push({ path: target, status: 0 });
|
|
318
325
|
prewarmProgress.entries.push({
|
|
319
326
|
path: target,
|
|
320
327
|
status: 0,
|
|
@@ -339,7 +346,66 @@ async function crawl(origin, paths, concurrency, report = undefined, pace = unde
|
|
|
339
346
|
Array.from({ length: Math.min(concurrency, paths.length) }, worker),
|
|
340
347
|
);
|
|
341
348
|
|
|
342
|
-
return { ok, failed,
|
|
349
|
+
return { ok, failed, failures };
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/**
|
|
353
|
+
* Tekrar turuna girecek yollar. Yalnızca geçici hatalar: `400`/`403`/`404` gibi
|
|
354
|
+
* deterministik cevaplar tekrar denemekle düzelmez ve o çağrılar kotadan
|
|
355
|
+
* karşılıksız yiyor. Sınıflandırma render tarafıyla aynı listeden
|
|
356
|
+
* (`isTransientStatus`), yoksa iki yer birbirinden kayar.
|
|
357
|
+
*
|
|
358
|
+
* @param {{ path: string, status: number }[]} failures
|
|
359
|
+
* @returns {string[]}
|
|
360
|
+
*/
|
|
361
|
+
function retryablePaths(failures) {
|
|
362
|
+
return failures
|
|
363
|
+
.filter((failure) => isTransientStatus(failure.status))
|
|
364
|
+
.map((failure) => failure.path);
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/**
|
|
368
|
+
* Tekrar turundan önce beklenecek süre.
|
|
369
|
+
*
|
|
370
|
+
* Sabit bekleme yanlış soruyu cevaplıyordu: doğru süreyi upstream biliyor.
|
|
371
|
+
* Hız freni açıkken `Retry-After` ya da devre kesicinin soğuma süresi zaten
|
|
372
|
+
* elimizde; 10 saniye kapalı kalacak bir kesiciden 2 saniye sonra tekrar
|
|
373
|
+
* denemek, aynı 429'u peşin peşin almak demek.
|
|
374
|
+
*
|
|
375
|
+
* @returns {number} ms
|
|
376
|
+
*/
|
|
377
|
+
function retryDelayMs() {
|
|
378
|
+
const configured = setting("PREWARM_RETRY_DELAY_MS", "retryDelayMs", 2000);
|
|
379
|
+
const cooldown = upstreamCooldownMs();
|
|
380
|
+
|
|
381
|
+
// Fren kapalıysa `cooldown` 0 olur ve davranış eskisi gibi kalır. Üst sınır
|
|
382
|
+
// turun sonsuza kadar açık kalmasını engelliyor.
|
|
383
|
+
return Math.min(Math.max(configured, cooldown), 60_000);
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/**
|
|
387
|
+
* Turun veri önbelleği üzerinden upstream'e ne kadar dokunduğunu özetler.
|
|
388
|
+
*
|
|
389
|
+
* @param {ReturnType<typeof getDataCacheStats>} before Tur başındaki sayaçlar.
|
|
390
|
+
* @returns {string} Okunacak bir şey yoksa boş dize.
|
|
391
|
+
*/
|
|
392
|
+
function upstreamUsage(before) {
|
|
393
|
+
const after = getDataCacheStats();
|
|
394
|
+
const reads = after.reads - before.reads;
|
|
395
|
+
if (reads <= 0) return "";
|
|
396
|
+
|
|
397
|
+
const produced = after.produced - before.produced;
|
|
398
|
+
const coalesced = after.coalesced - before.coalesced;
|
|
399
|
+
const shared = after.shared - before.shared;
|
|
400
|
+
const served = reads - produced;
|
|
401
|
+
const ratio = Math.round((served / reads) * 100);
|
|
402
|
+
|
|
403
|
+
return (
|
|
404
|
+
`${produced} upstream call${produced === 1 ? "" : "s"} for ${reads} data read${reads === 1 ? "" : "s"} ` +
|
|
405
|
+
`(${ratio}% from the data cache` +
|
|
406
|
+
`${coalesced ? `, ${coalesced} coalesced` : ""}` +
|
|
407
|
+
`${shared ? `, ${shared} from the shared tier` : ""})`
|
|
408
|
+
);
|
|
343
409
|
}
|
|
344
410
|
|
|
345
411
|
/**
|
|
@@ -392,13 +458,18 @@ export async function prewarm({ origin, quiet = false, paths: only }) {
|
|
|
392
458
|
});
|
|
393
459
|
suppressed.clear();
|
|
394
460
|
|
|
461
|
+
// Kotanın gerçekten ne kadar harcandığı ancak veri önbelleğinden görülür:
|
|
462
|
+
// 400 sayfalık bir tur, ortak bir uç için tek çağrı da yapabilir dört yüz de.
|
|
463
|
+
const dataBefore = getDataCacheStats();
|
|
464
|
+
|
|
395
465
|
let ok = 0;
|
|
396
466
|
let failed = 0;
|
|
397
467
|
let recovered = 0;
|
|
468
|
+
let skippedRetry = 0;
|
|
398
469
|
try {
|
|
399
|
-
/** @type {string[]} */
|
|
400
|
-
let
|
|
401
|
-
({ ok, failed,
|
|
470
|
+
/** @type {{ path: string, status: number }[]} */
|
|
471
|
+
let failures;
|
|
472
|
+
({ ok, failed, failures } = await crawl(
|
|
402
473
|
origin,
|
|
403
474
|
paths,
|
|
404
475
|
concurrency,
|
|
@@ -409,18 +480,18 @@ export async function prewarm({ origin, quiet = false, paths: only }) {
|
|
|
409
480
|
// Hatalar çoğunlukla upstream rate limit'i (429): ilk tur yüzlerce sayfayı
|
|
410
481
|
// aynı anda çekerken API'yi zorluyor. Tek seri tekrar turu bu sayfaların
|
|
411
482
|
// önbelleğe girmesini sağlıyor; aksi hâlde ziyaretçi soğuk render'ı öder.
|
|
412
|
-
|
|
483
|
+
const retryPaths = retryablePaths(failures);
|
|
484
|
+
skippedRetry = failures.length - retryPaths.length;
|
|
485
|
+
|
|
486
|
+
if (retryPaths.length) {
|
|
413
487
|
const firstOk = ok;
|
|
414
488
|
const firstFailed = failed;
|
|
415
489
|
|
|
416
|
-
|
|
417
|
-
// 429'u almak demek.
|
|
418
|
-
const retryDelay = setting("PREWARM_RETRY_DELAY_MS", "retryDelayMs", 2000);
|
|
419
|
-
await sleep(retryDelay);
|
|
490
|
+
await sleep(retryDelayMs());
|
|
420
491
|
|
|
421
492
|
const retry = await crawl(
|
|
422
493
|
origin,
|
|
423
|
-
|
|
494
|
+
retryPaths,
|
|
424
495
|
1,
|
|
425
496
|
(retriedOk) => {
|
|
426
497
|
// Tekrar turunda her başarı bir hatayı başarıya çevirir.
|
|
@@ -450,10 +521,18 @@ export async function prewarm({ origin, quiet = false, paths: only }) {
|
|
|
450
521
|
`${pending.length ? `, ${pending.length} invalidated` : ""}` +
|
|
451
522
|
`${failed ? `, ${failed} failed` : ""}` +
|
|
452
523
|
`${recovered ? `, ${recovered} recovered on the retry pass` : ""}` +
|
|
524
|
+
`${skippedRetry ? `, ${skippedRetry} not retried (permanent)` : ""}` +
|
|
453
525
|
`${skipped > 0 ? `, ${skipped} ${rotate ? "deferred to the next pass" : "over the limit"}` : ""}` +
|
|
454
526
|
` (${(elapsed / 1000).toFixed(1)}s)`,
|
|
455
527
|
);
|
|
456
528
|
|
|
529
|
+
// Turun upstream'e ne kadar dokunduğu. Asıl karar bu satıra bakılarak
|
|
530
|
+
// veriliyor: oran düşükse çözüm hız freni değil, `withDataCache` TTL'ini
|
|
531
|
+
// tur aralığından uzun tutmak — fren çağrıları yavaşlatır, sayısını
|
|
532
|
+
// azaltmaz.
|
|
533
|
+
const upstreamCalls = upstreamUsage(dataBefore);
|
|
534
|
+
if (upstreamCalls) console.log(`[prewarm] ${upstreamCalls}`);
|
|
535
|
+
|
|
457
536
|
// Tur boyunca bastırılan hatalar: en sık görülenler önce, liste uzarsa
|
|
458
537
|
// kalanı tek satırda toplanır. Amaç, logu şişirmeden "ne bozuldu"yu
|
|
459
538
|
// görünür tutmak.
|
package/src/server/redis.js
CHANGED
|
@@ -405,6 +405,114 @@ export function getRedisStatus() {
|
|
|
405
405
|
};
|
|
406
406
|
}
|
|
407
407
|
|
|
408
|
+
/**
|
|
409
|
+
* Bağlantının **nereye** kurulduğu ve hangi ayarlarla çalıştığı.
|
|
410
|
+
*
|
|
411
|
+
* Şifre asla dönmez: bağlantı URL'i `redis://user:pass@host` biçiminde
|
|
412
|
+
* olabiliyor ve panelin işi adresi göstermek, sırrı değil. Ayrıştırılamayan
|
|
413
|
+
* bir URL için adres `"custom"` olur — bozuk bir değer teşhis ucunu
|
|
414
|
+
* düşürmemeli.
|
|
415
|
+
*
|
|
416
|
+
* @returns {{ address: string, secure: boolean, db: string | null,
|
|
417
|
+
* namespace: string, keyPrefix: string, html: boolean, data: boolean,
|
|
418
|
+
* storeEncoded: boolean, events: boolean, commandTimeoutMs: number,
|
|
419
|
+
* subscribed: boolean }}
|
|
420
|
+
*/
|
|
421
|
+
export function getRedisDetails() {
|
|
422
|
+
let address = "localhost:6379 (ioredis default)";
|
|
423
|
+
let secure = false;
|
|
424
|
+
/** @type {string | null} */
|
|
425
|
+
let db = null;
|
|
426
|
+
|
|
427
|
+
if (settings.url) {
|
|
428
|
+
try {
|
|
429
|
+
const parsed = new URL(settings.url);
|
|
430
|
+
address = `${parsed.hostname}:${parsed.port || 6379}`;
|
|
431
|
+
secure = parsed.protocol === "rediss:";
|
|
432
|
+
const name = parsed.pathname.replace(/^\//, "");
|
|
433
|
+
db = name || null;
|
|
434
|
+
} catch {
|
|
435
|
+
address = "custom";
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
return {
|
|
440
|
+
address,
|
|
441
|
+
secure,
|
|
442
|
+
db,
|
|
443
|
+
namespace: settings.namespace,
|
|
444
|
+
keyPrefix: settings.keyPrefix,
|
|
445
|
+
html: settings.html === true,
|
|
446
|
+
data: settings.data === true,
|
|
447
|
+
storeEncoded: settings.storeEncoded === true,
|
|
448
|
+
events: settings.events === true,
|
|
449
|
+
commandTimeoutMs: settings.commandTimeoutMs,
|
|
450
|
+
subscribed: Boolean(subscriber),
|
|
451
|
+
};
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
/**
|
|
455
|
+
* Paylaşımlı kademede gerçekten **ne durduğunu** sayar: tür başına anahtar
|
|
456
|
+
* sayısı ve sunucunun bildirdiği bellek kullanımı.
|
|
457
|
+
*
|
|
458
|
+
* Ayrı bir çağrı olması gerekiyor. Sayım `SCAN` turu demek ve panelin döküm
|
|
459
|
+
* ucu birkaç saniyede bir yenileniyor; her turda tüm keyspace'i taramak
|
|
460
|
+
* Redis'i teşhis uğruna yormak olurdu. Panel bunu düğmeye basınca çağırır.
|
|
461
|
+
*
|
|
462
|
+
* @returns {Promise<{ ok: boolean, html: number, data: number,
|
|
463
|
+
* usedMemory: string | null, totalKeys: number | null }>}
|
|
464
|
+
*/
|
|
465
|
+
export async function inspectRedis() {
|
|
466
|
+
if (!usable()) return { ok: false, html: 0, data: 0, usedMemory: null, totalKeys: null };
|
|
467
|
+
|
|
468
|
+
try {
|
|
469
|
+
const [html, data] = await Promise.all([count("html"), count("data")]);
|
|
470
|
+
|
|
471
|
+
/** @type {string | null} */
|
|
472
|
+
let usedMemory = null;
|
|
473
|
+
/** @type {number | null} */
|
|
474
|
+
let totalKeys = null;
|
|
475
|
+
|
|
476
|
+
try {
|
|
477
|
+
const info = await client.info("memory");
|
|
478
|
+
usedMemory = /used_memory_human:(\S+)/.exec(String(info))?.[1] ?? null;
|
|
479
|
+
totalKeys = Number(await client.dbsize());
|
|
480
|
+
} catch {
|
|
481
|
+
// `INFO`/`DBSIZE` kısıtlı bir kurulumda (managed Redis) reddedilebilir;
|
|
482
|
+
// anahtar sayıları yine geçerli.
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
noteSuccess();
|
|
486
|
+
return { ok: true, html, data, usedMemory, totalKeys };
|
|
487
|
+
} catch (error) {
|
|
488
|
+
noteFailure("inspect", error);
|
|
489
|
+
return { ok: false, html: 0, data: 0, usedMemory: null, totalKeys: null };
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
/**
|
|
494
|
+
* @param {"html" | "data"} kind
|
|
495
|
+
* @returns {Promise<number>}
|
|
496
|
+
*/
|
|
497
|
+
async function count(kind) {
|
|
498
|
+
let cursor = "0";
|
|
499
|
+
let total = 0;
|
|
500
|
+
|
|
501
|
+
do {
|
|
502
|
+
const [next, keys] = await client.scan(
|
|
503
|
+
cursor,
|
|
504
|
+
"MATCH",
|
|
505
|
+
`${prefix}:${kind}:*`,
|
|
506
|
+
"COUNT",
|
|
507
|
+
500,
|
|
508
|
+
);
|
|
509
|
+
cursor = next;
|
|
510
|
+
total += keys.length;
|
|
511
|
+
} while (cursor !== "0");
|
|
512
|
+
|
|
513
|
+
return total;
|
|
514
|
+
}
|
|
515
|
+
|
|
408
516
|
/**
|
|
409
517
|
* Bağlantıları kapatır. `SIGTERM` sonrası uçuştaki komutların bitmesi
|
|
410
518
|
* beklenir (`quit`), zorla kesilmez.
|
package/src/server/render.js
CHANGED
|
@@ -207,8 +207,13 @@ export function route(controller, options = {}) {
|
|
|
207
207
|
const revalidate = isPrivate
|
|
208
208
|
? undefined
|
|
209
209
|
: resolveRevalidate(req.path, options.revalidate);
|
|
210
|
-
|
|
211
|
-
|
|
210
|
+
// Anahtar `null` ise query bu yol için cache'lenebilir değil: sayfa
|
|
211
|
+
// dinamik davranır. Anahtar yine de gerekiyor (hata sayfası ölçümü,
|
|
212
|
+
// teşhis) ama TTL sıfırlanıp cache yolu kapatılır.
|
|
213
|
+
const key = buildCacheKey(req.path, ctx.query);
|
|
214
|
+
const cacheable =
|
|
215
|
+
!isPrivate && req.method === "GET" && Boolean(revalidate) && key !== null;
|
|
216
|
+
const cacheKey = key ?? `${req.path}?${new URLSearchParams(
|
|
212
217
|
Object.entries(ctx.query).map(([k, v]) => [k, String(v)]),
|
|
213
218
|
).toString()}`;
|
|
214
219
|
|
|
@@ -409,6 +414,73 @@ const revalidateByPath = new Map();
|
|
|
409
414
|
*/
|
|
410
415
|
const REVALIDATE_CACHE_MAX = 2000;
|
|
411
416
|
|
|
417
|
+
/**
|
|
418
|
+
* Aynı gerekçeyle (yakalayıcı route'ta sınırsız büyüme) query kuralı da yol
|
|
419
|
+
* başına hatırlanır.
|
|
420
|
+
*
|
|
421
|
+
* @type {Map<string, true | string[] | null>}
|
|
422
|
+
*/
|
|
423
|
+
const queryPolicyByPath = new Map();
|
|
424
|
+
|
|
425
|
+
/**
|
|
426
|
+
* @param {string} pathname
|
|
427
|
+
* @returns {true | string[] | null} `null`: bu yol için hiçbir parametreye
|
|
428
|
+
* izin verilmiyor.
|
|
429
|
+
*/
|
|
430
|
+
function resolveQueryPolicy(pathname) {
|
|
431
|
+
if (queryPolicyByPath.has(pathname)) {
|
|
432
|
+
return queryPolicyByPath.get(pathname) ?? null;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
const rules = getConfig().cacheQuery;
|
|
436
|
+
const match = rules.find((rule) => matchPattern(rule.pattern, pathname));
|
|
437
|
+
|
|
438
|
+
if (queryPolicyByPath.size >= REVALIDATE_CACHE_MAX) {
|
|
439
|
+
const oldest = queryPolicyByPath.keys().next().value;
|
|
440
|
+
if (oldest !== undefined) queryPolicyByPath.delete(oldest);
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
const policy = match ? match.allow : null;
|
|
444
|
+
queryPolicyByPath.set(pathname, policy);
|
|
445
|
+
return policy;
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
/**
|
|
449
|
+
* HTML cache anahtarı, ya da query bu yol için cache'lenebilir değilse `null`.
|
|
450
|
+
*
|
|
451
|
+
* Varsayılan olarak query parametresi taşıyan istek dinamiktir: `cache().query`
|
|
452
|
+
* altında eşleşen bir kural olmadıkça cache'e hiç girmez. Aksi hâlde bir yolun
|
|
453
|
+
* bütün `?utm_source=…` varyantları ayrı girdi olur ve LRU'daki gerçek
|
|
454
|
+
* sayfaları dışarı atar.
|
|
455
|
+
*
|
|
456
|
+
* İzin verilen parametreler **sıralı** yazılır: `?a=1&b=2` ile `?b=2&a=1` aynı
|
|
457
|
+
* sayfa olduğu için aynı anahtarı almalı.
|
|
458
|
+
*
|
|
459
|
+
* @param {string} pathname
|
|
460
|
+
* @param {Record<string, unknown>} query
|
|
461
|
+
* @returns {string | null}
|
|
462
|
+
*/
|
|
463
|
+
function buildCacheKey(pathname, query) {
|
|
464
|
+
const entries = Object.entries(query);
|
|
465
|
+
if (!entries.length) return `${pathname}?`;
|
|
466
|
+
|
|
467
|
+
const policy = resolveQueryPolicy(pathname);
|
|
468
|
+
if (policy === null) return null;
|
|
469
|
+
|
|
470
|
+
const kept =
|
|
471
|
+
policy === true ? entries : entries.filter(([name]) => policy.includes(name));
|
|
472
|
+
|
|
473
|
+
// İzin listesi dışındaki parametreler anahtara girmez: sayfa cache'lenir ve
|
|
474
|
+
// bütün kampanya varyantları tek kopyayı paylaşır.
|
|
475
|
+
const params = new URLSearchParams(
|
|
476
|
+
kept
|
|
477
|
+
.map(/** @returns {[string, string]} */ ([name, value]) => [name, String(value)])
|
|
478
|
+
.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)),
|
|
479
|
+
);
|
|
480
|
+
|
|
481
|
+
return `${pathname}?${params.toString()}`;
|
|
482
|
+
}
|
|
483
|
+
|
|
412
484
|
/**
|
|
413
485
|
* @param {string} pathname
|
|
414
486
|
* @param {number | undefined} fallback
|