jskelet 0.1.2 → 0.1.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 +87 -4
- package/docs/06-cache.md +255 -20
- package/docs/07-yapilandirma.md +75 -6
- package/docs/en/06-caching.md +264 -23
- package/docs/en/07-configuration.md +78 -6
- package/package.json +1 -1
- package/src/config/defaults.js +63 -0
- package/src/config/index.js +82 -5
- package/src/index.js +7 -0
- package/src/server/create-app.js +6 -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 +155 -29
- package/src/server/upstream-tracking.js +103 -13
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
|
@@ -27,7 +27,11 @@ import {
|
|
|
27
27
|
guardRequest,
|
|
28
28
|
withRequestContext,
|
|
29
29
|
} from "../http/request-context.js";
|
|
30
|
-
import {
|
|
30
|
+
import {
|
|
31
|
+
getUpstreamFailures,
|
|
32
|
+
isTransientStatus,
|
|
33
|
+
withUpstreamTracking,
|
|
34
|
+
} from "./upstream-tracking.js";
|
|
31
35
|
import { isNotFoundError, isRedirectError } from "../http/control-flow.js";
|
|
32
36
|
import { renderHeadMeta } from "./metadata.js";
|
|
33
37
|
import { asset, hasAsset } from "./assets.js";
|
|
@@ -226,20 +230,35 @@ export function route(controller, options = {}) {
|
|
|
226
230
|
);
|
|
227
231
|
}
|
|
228
232
|
|
|
229
|
-
|
|
233
|
+
// Eksik veriyle üretilen çıktı süreç içi önbelleğe yazılmıyor; aynı
|
|
234
|
+
// çıktıya CDN'de `s-maxage` vermek o kararı bir katman yukarıda geri
|
|
235
|
+
// almak olurdu. Geçici bir 429 yüzünden üretilen 503, ters proxy'de
|
|
236
|
+
// dakikalarca yaşamamalı.
|
|
237
|
+
const publicCache = cacheable && !leaked && !result.degraded;
|
|
230
238
|
|
|
231
239
|
res.status(result.status);
|
|
232
240
|
res.setHeader("Content-Type", "text/html; charset=utf-8");
|
|
233
241
|
|
|
242
|
+
// Geçici upstream hatası: istemciye ve bota "bu kalıcı değil, sonra gel"
|
|
243
|
+
// demenin standart yolu.
|
|
244
|
+
if (result.retryAfter) {
|
|
245
|
+
res.setHeader("Retry-After", String(result.retryAfter));
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// Teşhis başlığı `degraded` yanıtta da yazılır: "MISS" görmek, sayfanın
|
|
249
|
+
// önbellek yolundan geçtiğini ama saklanmadığını anlatan tek ipucu.
|
|
250
|
+
if (cacheable && !leaked) {
|
|
251
|
+
res.setHeader(
|
|
252
|
+
getConfig().brand.cacheHeader,
|
|
253
|
+
result.cached ? (result.stale ? "STALE" : "HIT") : "MISS",
|
|
254
|
+
);
|
|
255
|
+
}
|
|
256
|
+
|
|
234
257
|
if (publicCache) {
|
|
235
258
|
res.setHeader(
|
|
236
259
|
"Cache-Control",
|
|
237
260
|
`public, max-age=0, s-maxage=${revalidate}, stale-while-revalidate=60`,
|
|
238
261
|
);
|
|
239
|
-
res.setHeader(
|
|
240
|
-
getConfig().brand.cacheHeader,
|
|
241
|
-
result.cached ? (result.stale ? "STALE" : "HIT") : "MISS",
|
|
242
|
-
);
|
|
243
262
|
} else {
|
|
244
263
|
res.setHeader("Cache-Control", PRIVATE_CACHE);
|
|
245
264
|
// Anahtarında cookie olmayan bir cache'in bu yanıtı paylaşmasını
|
|
@@ -456,39 +475,135 @@ async function sendHtml(req, res, body, encoded, options = {}) {
|
|
|
456
475
|
}
|
|
457
476
|
|
|
458
477
|
/**
|
|
478
|
+
* @typedef {{ html: string, status: number, degraded?: boolean,
|
|
479
|
+
* storable?: boolean, retryAfter?: number }} Produced
|
|
480
|
+
*/
|
|
481
|
+
|
|
482
|
+
/**
|
|
483
|
+
* Controller'ı bir kez çalıştırır. `notFound()` fırlatıldığında sonucu
|
|
484
|
+
* ayırt edilebilir biçimde döner: çağıran taraf bunun gerçek bir 404 mü,
|
|
485
|
+
* yoksa upstream düştüğü için verinin gelmemesi mi olduğuna karar verecek.
|
|
486
|
+
*
|
|
459
487
|
* @param {Function} controller
|
|
460
488
|
* @param {{ pathname: string }} ctx
|
|
461
|
-
* @returns {Promise<{
|
|
462
|
-
*
|
|
489
|
+
* @returns {Promise<{ page: Produced } | { notFound: true,
|
|
490
|
+
* transient: import('./upstream-tracking.js').UpstreamFailure[] }>}
|
|
463
491
|
*/
|
|
464
|
-
async function
|
|
492
|
+
async function attempt(controller, ctx) {
|
|
465
493
|
try {
|
|
466
494
|
const page = await controller(ctx);
|
|
467
495
|
const rendered = await renderPage({ pathname: ctx.pathname, ...page });
|
|
468
496
|
return {
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
497
|
+
page: {
|
|
498
|
+
html: rendered,
|
|
499
|
+
status: page.status ?? 200,
|
|
500
|
+
degraded: hasUpstreamFailures(ctx.pathname),
|
|
501
|
+
// Kimliğe bağlı çıktı önbelleğe yazılmaz. Karar burada verilmeli:
|
|
502
|
+
// `withHtmlCache` yazma anında controller'ın ne okuduğunu bilemez.
|
|
503
|
+
storable: getRequestContext()?.tainted !== true,
|
|
504
|
+
},
|
|
475
505
|
};
|
|
476
506
|
} catch (error) {
|
|
477
507
|
if (isNotFoundError(error)) {
|
|
478
|
-
return {
|
|
508
|
+
return { notFound: true, transient: transientUpstreamFailures() };
|
|
479
509
|
}
|
|
480
510
|
throw error;
|
|
481
511
|
}
|
|
482
512
|
}
|
|
483
513
|
|
|
484
|
-
/**
|
|
485
|
-
|
|
514
|
+
/**
|
|
515
|
+
* @param {Function} controller
|
|
516
|
+
* @param {{ pathname: string }} ctx
|
|
517
|
+
* @returns {Promise<Produced>}
|
|
518
|
+
*/
|
|
519
|
+
async function produce(controller, ctx) {
|
|
520
|
+
const first = await attempt(controller, ctx);
|
|
521
|
+
if ("page" in first) return first.page;
|
|
522
|
+
// Deterministik "böyle bir sayfa yok" cevabı: tekrar denemenin anlamı yok.
|
|
523
|
+
if (!first.transient.length) return { html: await renderNotFound(), status: 404 };
|
|
524
|
+
|
|
525
|
+
// Buraya gelindiyse `notFound()` veri gelmediği için çağrılmış. **Var olan
|
|
526
|
+
// bir sayfayı** 404 olarak servis etmek en kötü sonuç: arama motoru geçici
|
|
527
|
+
// bir rate limit'i kalıcı bir kayıp sanar. Bu yüzden sayfa yeniden denenir —
|
|
528
|
+
// ısıtma günlükleri gösteriyor ki aynı yol saniyeler sonra 200 dönüyor.
|
|
529
|
+
const { attempts, delayMs } = transientRetry();
|
|
530
|
+
let failures = first.transient;
|
|
531
|
+
|
|
532
|
+
for (let round = 1; round <= attempts; round += 1) {
|
|
533
|
+
console.warn(
|
|
534
|
+
`[render] ${ctx.pathname} returned notFound() while upstream is failing ` +
|
|
535
|
+
`(${summarizeFailures(failures)}), retrying (${round}/${attempts})`,
|
|
536
|
+
);
|
|
537
|
+
|
|
538
|
+
// Beklemeden tekrar denemek rate limit'e girmiş bir API'de aynı 429'u
|
|
539
|
+
// getirir; kısa bekleme hem pencerenin dönmesine şans verir hem de
|
|
540
|
+
// fırtınayı büyütmez.
|
|
541
|
+
await sleep(delayMs * round);
|
|
542
|
+
|
|
543
|
+
// Her deneme kendi upstream ve istek içi cache bağlamında çalışır: ilk
|
|
544
|
+
// turun hataları ikinci turun kararını kirletmesin ve memoize edilmiş
|
|
545
|
+
// boş cevaplar tekrar kullanılmasın.
|
|
546
|
+
const retried = await withUpstreamTracking(() =>
|
|
547
|
+
withRequestCache(() => attempt(controller, ctx)),
|
|
548
|
+
);
|
|
549
|
+
|
|
550
|
+
if ("page" in retried) return retried.page;
|
|
551
|
+
if (!retried.transient.length) {
|
|
552
|
+
// Bu kez upstream sağlam cevap verdi ve "yok" dedi: gerçek 404.
|
|
553
|
+
return { html: await renderNotFound(), status: 404 };
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
failures = retried.transient;
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
// Denemeler tükendi. 404 yerine 503: önbelleğe girmez, `Retry-After` taşır
|
|
560
|
+
// ve bir sonraki istek gerçek içeriği üretebilir.
|
|
561
|
+
console.warn(
|
|
562
|
+
`[render] ${ctx.pathname} could not be produced, upstream is still failing ` +
|
|
563
|
+
`(${summarizeFailures(failures)}), serving an uncached 503 instead of a 404`,
|
|
564
|
+
);
|
|
565
|
+
|
|
566
|
+
return {
|
|
567
|
+
html: await renderStatusPage(503),
|
|
568
|
+
status: 503,
|
|
569
|
+
degraded: true,
|
|
570
|
+
retryAfter: RETRY_AFTER_SECONDS,
|
|
571
|
+
};
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
/**
|
|
575
|
+
* Geçici bir upstream hatası yüzünden üretilemeyen sayfanın `Retry-After`
|
|
576
|
+
* değeri. Kısa tutuluyor: ziyaretçi de bot da birkaç saniye sonra gerçek
|
|
577
|
+
* içeriği bulabilsin.
|
|
578
|
+
*/
|
|
579
|
+
const RETRY_AFTER_SECONDS = 30;
|
|
580
|
+
|
|
581
|
+
/**
|
|
582
|
+
* Tekrar denemenin maliyeti upstream'e binen ikinci bir istek turu; bu yüzden
|
|
583
|
+
* varsayılan tek deneme ve kısa bekleme. Rate limit fırtınasında toplam yük
|
|
584
|
+
* iki katına çıkabilir, ama alternatifi var olan sayfaları 404'e düşürmek.
|
|
585
|
+
*
|
|
586
|
+
* @returns {{ attempts: number, delayMs: number }}
|
|
587
|
+
*/
|
|
588
|
+
function transientRetry() {
|
|
589
|
+
const raw = /** @type {any} */ (getConfig().transientRetry ?? {});
|
|
590
|
+
const attempts = Number(raw.attempts);
|
|
591
|
+
const delayMs = Number(raw.delayMs);
|
|
592
|
+
|
|
593
|
+
return {
|
|
594
|
+
attempts: Number.isFinite(attempts) && attempts >= 0 ? Math.floor(attempts) : 1,
|
|
595
|
+
delayMs: Number.isFinite(delayMs) && delayMs >= 0 ? delayMs : 300,
|
|
596
|
+
};
|
|
597
|
+
}
|
|
486
598
|
|
|
487
|
-
/** @param {number}
|
|
488
|
-
function
|
|
489
|
-
return
|
|
599
|
+
/** @param {number} ms */
|
|
600
|
+
function sleep(ms) {
|
|
601
|
+
return new Promise((resolve) => {
|
|
602
|
+
setTimeout(resolve, ms).unref?.();
|
|
603
|
+
});
|
|
490
604
|
}
|
|
491
605
|
|
|
606
|
+
|
|
492
607
|
/**
|
|
493
608
|
* Render sırasında upstream düştüyse çıktı eksik veri içeriyor demektir.
|
|
494
609
|
* Böyle bir HTML önbelleğe yazılmaz: sonraki istek yeniden dener.
|
|
@@ -506,28 +621,39 @@ function hasUpstreamFailures(pathname) {
|
|
|
506
621
|
const failures = getUpstreamFailures();
|
|
507
622
|
if (!failures.length) return false;
|
|
508
623
|
|
|
509
|
-
|
|
510
|
-
const
|
|
511
|
-
list.map((failure) => `${failure.status} ${failure.path}`).join(", ");
|
|
512
|
-
|
|
513
|
-
const transient = failures.filter((failure) => isTransient(failure.status));
|
|
514
|
-
const permanent = failures.filter((failure) => !isTransient(failure.status));
|
|
624
|
+
const transient = failures.filter((failure) => isTransientStatus(failure.status));
|
|
625
|
+
const permanent = failures.filter((failure) => !isTransientStatus(failure.status));
|
|
515
626
|
|
|
516
627
|
if (permanent.length) {
|
|
517
628
|
console.warn(
|
|
518
|
-
`[render] ${pathname} was produced with missing data, upstream is failing permanently (${
|
|
629
|
+
`[render] ${pathname} was produced with missing data, upstream is failing permanently (${summarizeFailures(permanent)})`,
|
|
519
630
|
);
|
|
520
631
|
}
|
|
521
632
|
|
|
522
633
|
if (!transient.length) return false;
|
|
523
634
|
|
|
524
635
|
console.warn(
|
|
525
|
-
`[render] ${pathname} was produced with missing data, not caching it (${
|
|
636
|
+
`[render] ${pathname} was produced with missing data, not caching it (${summarizeFailures(transient)})`,
|
|
526
637
|
);
|
|
527
638
|
|
|
528
639
|
return true;
|
|
529
640
|
}
|
|
530
641
|
|
|
642
|
+
/**
|
|
643
|
+
* @returns {import('./upstream-tracking.js').UpstreamFailure[]}
|
|
644
|
+
*/
|
|
645
|
+
function transientUpstreamFailures() {
|
|
646
|
+
return getUpstreamFailures().filter((failure) => isTransientStatus(failure.status));
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
/**
|
|
650
|
+
* @param {import('./upstream-tracking.js').UpstreamFailure[]} failures
|
|
651
|
+
* @returns {string}
|
|
652
|
+
*/
|
|
653
|
+
function summarizeFailures(failures) {
|
|
654
|
+
return failures.map((failure) => `${failure.status} ${failure.path}`).join(", ");
|
|
655
|
+
}
|
|
656
|
+
|
|
531
657
|
/**
|
|
532
658
|
* 404 sayfası. `renderStatusPage(404)` için kısayol; route dosyalarında en sık
|
|
533
659
|
* ihtiyaç duyulan durum bu olduğu için ayrı bir ad taşımaya devam ediyor.
|
|
@@ -1,22 +1,25 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Render başına upstream API hatalarını toplar.
|
|
3
3
|
*
|
|
4
|
-
* `render.js` her sayfayı bu bağlam içinde üretir
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
* bozuk sayfayı saklamaz.
|
|
4
|
+
* `render.js` her sayfayı bu bağlam içinde üretir. Böylece HTML önbelleği "bu
|
|
5
|
+
* çıktı eksik veriyle üretildi" bilgisine sahip olur ve bozuk sayfayı saklamaz;
|
|
6
|
+
* `notFound()` de geçici bir hataya denk geldiğinde 404 olmaktan çıkar.
|
|
8
7
|
*
|
|
9
|
-
*
|
|
10
|
-
* tanımaz, veri katmanı framework'e haber verir. Hiç çağıran olmazsa maliyet
|
|
11
|
-
* boş bir dizidir.
|
|
8
|
+
* Bilgi iki yoldan gelir:
|
|
12
9
|
*
|
|
13
|
-
*
|
|
10
|
+
* 1. **Otomatik** — `trackUpstreamFetch()` `globalThis.fetch`i sarar ve
|
|
11
|
+
* geçici hataları (429, 5xx, ağ) kendiliğinden bildirir. `createApp()`
|
|
12
|
+
* bunu açılışta kurar, yani hiçbir uygulama kodu gerekmez.
|
|
13
|
+
* 2. **Elle** — `fetch` kullanmayan bir istemci (veritabanı sürücüsü, gRPC,
|
|
14
|
+
* SDK) için:
|
|
14
15
|
*
|
|
15
|
-
*
|
|
16
|
+
* import { reportUpstreamFailure } from "jskelet";
|
|
16
17
|
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
18
|
+
* if (!response.ok) {
|
|
19
|
+
* reportUpstreamFailure({ status: response.status, path: url });
|
|
20
|
+
* }
|
|
21
|
+
*
|
|
22
|
+
* İki yol aynı hatayı bildirirse tekilleştirilir.
|
|
20
23
|
*/
|
|
21
24
|
import { AsyncLocalStorage } from "node:async_hooks";
|
|
22
25
|
|
|
@@ -33,7 +36,94 @@ const storage = new AsyncLocalStorage();
|
|
|
33
36
|
* @returns {void}
|
|
34
37
|
*/
|
|
35
38
|
export function reportUpstreamFailure(failure) {
|
|
36
|
-
storage.getStore()
|
|
39
|
+
const store = storage.getStore();
|
|
40
|
+
if (!store) return;
|
|
41
|
+
|
|
42
|
+
// Aynı hatayı hem otomatik sarmalayıcı hem uygulamanın istemcisi
|
|
43
|
+
// bildirebilir; aynı satırı iki kez loglamanın faydası yok.
|
|
44
|
+
const duplicate = store.failures.some(
|
|
45
|
+
(existing) => existing.status === failure.status && existing.path === failure.path,
|
|
46
|
+
);
|
|
47
|
+
if (!duplicate) store.failures.push(failure);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Geçici sayılan durumlar: tekrar denemekle düzelebilenler. Bu liste
|
|
52
|
+
* `render.js` ile paylaşılır — hangi hatanın önbelleği engellediği ve hangi
|
|
53
|
+
* hatanın `notFound()`u 404 olmaktan çıkardığı tek yerde tanımlı olsun.
|
|
54
|
+
*/
|
|
55
|
+
const TRANSIENT_STATUSES = new Set([0, 408, 425, 429]);
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* @param {number} status
|
|
59
|
+
* @returns {boolean}
|
|
60
|
+
*/
|
|
61
|
+
export function isTransientStatus(status) {
|
|
62
|
+
return TRANSIENT_STATUSES.has(status) || status >= 500;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* `globalThis.fetch`i sarıp **geçici** upstream hatalarını kendiliğinden
|
|
67
|
+
* bildirir.
|
|
68
|
+
*
|
|
69
|
+
* Gerekçesi pratik: `reportUpstreamFailure()` sözleşmesi uygulamanın HTTP
|
|
70
|
+
* istemcisine bir satır eklemeyi gerektiriyor ve o satır yazılmadığında
|
|
71
|
+
* framework rate limit'i hiç göremiyor — veri gelmediği için `notFound()`
|
|
72
|
+
* çağıran sayfa 404 olarak servis ediliyordu. Otomatik izleme bu bilgiyi
|
|
73
|
+
* varsayılan hâle getirir; elle çağrı hâlâ geçerli ve tekilleştirilir.
|
|
74
|
+
*
|
|
75
|
+
* Yalnızca geçici durumlar bildirilir. `404`/`403` gibi deterministik
|
|
76
|
+
* cevaplar birçok API'de "böyle bir kayıt yok" anlamına geliyor ve onları
|
|
77
|
+
* otomatik olarak "eksik veri" saymak her sayfada yanlış uyarı üretirdi.
|
|
78
|
+
*
|
|
79
|
+
* Kendi sunucumuza yapılan istekler atlanır: ısıtma turu ve sağlık kontrolü
|
|
80
|
+
* upstream değil.
|
|
81
|
+
*
|
|
82
|
+
* @returns {void}
|
|
83
|
+
*/
|
|
84
|
+
export function trackUpstreamFetch() {
|
|
85
|
+
const original = globalThis.fetch;
|
|
86
|
+
if (/** @type {any} */ (original).__jskeletUpstreamTracked) return;
|
|
87
|
+
|
|
88
|
+
/** @type {typeof fetch} */
|
|
89
|
+
const wrapped = async (input, init) => {
|
|
90
|
+
// İstek bir render bağlamı içinde değilse (script, zamanlayıcı) hiçbir
|
|
91
|
+
// şey yapılmaz: sarmalayıcının maliyeti bir `getStore()` çağrısı.
|
|
92
|
+
if (!storage.getStore()) return original(input, init);
|
|
93
|
+
|
|
94
|
+
const url = requestUrl(input);
|
|
95
|
+
if (isSelfRequest(url)) return original(input, init);
|
|
96
|
+
|
|
97
|
+
try {
|
|
98
|
+
const response = await original(input, init);
|
|
99
|
+
if (!response.ok && isTransientStatus(response.status)) {
|
|
100
|
+
reportUpstreamFailure({ status: response.status, path: url });
|
|
101
|
+
}
|
|
102
|
+
return response;
|
|
103
|
+
} catch (error) {
|
|
104
|
+
// Yanıt hiç gelmedi: ağ hatası her zaman geçicidir.
|
|
105
|
+
reportUpstreamFailure({ status: 0, path: url });
|
|
106
|
+
throw error;
|
|
107
|
+
}
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
/** @type {any} */ (wrapped).__jskeletUpstreamTracked = true;
|
|
111
|
+
globalThis.fetch = wrapped;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* @param {RequestInfo | URL} input
|
|
116
|
+
* @returns {string}
|
|
117
|
+
*/
|
|
118
|
+
function requestUrl(input) {
|
|
119
|
+
if (typeof input === "string") return input;
|
|
120
|
+
if (input instanceof URL) return input.href;
|
|
121
|
+
return /** @type {Request} */ (input)?.url ?? String(input);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** @param {string} url */
|
|
125
|
+
function isSelfRequest(url) {
|
|
126
|
+
return /^https?:\/\/(127\.0\.0\.1|\[::1\]|localhost)(:|\/|$)/i.test(url);
|
|
37
127
|
}
|
|
38
128
|
|
|
39
129
|
/**
|