jskelet 0.1.3 → 0.1.5
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 +31 -6
- package/docs/06-cache.md +65 -12
- package/docs/07-yapilandirma.md +20 -2
- package/docs/09-dev-araclari.md +27 -5
- package/docs/en/06-caching.md +70 -15
- package/docs/en/07-configuration.md +21 -2
- package/docs/en/09-dev-tools.md +26 -3
- package/package.json +1 -1
- package/src/client/devtools/overlay.js +111 -46
- package/src/config/defaults.js +14 -0
- package/src/config/index.js +24 -3
- package/src/server/create-app.js +14 -1
- package/src/server/dev/devtools.js +106 -15
- package/src/server/dev/socket.js +157 -0
- package/src/server/render.js +111 -38
- package/src/server/upstream-tracking.js +103 -13
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";
|
|
@@ -471,49 +475,100 @@ async function sendHtml(req, res, body, encoded, options = {}) {
|
|
|
471
475
|
}
|
|
472
476
|
|
|
473
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
|
+
*
|
|
474
487
|
* @param {Function} controller
|
|
475
488
|
* @param {{ pathname: string }} ctx
|
|
476
|
-
* @returns {Promise<{
|
|
477
|
-
*
|
|
489
|
+
* @returns {Promise<{ page: Produced } | { notFound: true,
|
|
490
|
+
* transient: import('./upstream-tracking.js').UpstreamFailure[] }>}
|
|
478
491
|
*/
|
|
479
|
-
async function
|
|
492
|
+
async function attempt(controller, ctx) {
|
|
480
493
|
try {
|
|
481
494
|
const page = await controller(ctx);
|
|
482
495
|
const rendered = await renderPage({ pathname: ctx.pathname, ...page });
|
|
483
496
|
return {
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
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
|
+
},
|
|
490
505
|
};
|
|
491
506
|
} catch (error) {
|
|
492
507
|
if (isNotFoundError(error)) {
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
508
|
+
return { notFound: true, transient: transientUpstreamFailures() };
|
|
509
|
+
}
|
|
510
|
+
throw error;
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
|
|
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
|
+
);
|
|
512
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.
|
|
513
553
|
return { html: await renderNotFound(), status: 404 };
|
|
514
554
|
}
|
|
515
|
-
|
|
555
|
+
|
|
556
|
+
failures = retried.transient;
|
|
516
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
|
+
};
|
|
517
572
|
}
|
|
518
573
|
|
|
519
574
|
/**
|
|
@@ -523,14 +578,32 @@ async function produce(controller, ctx) {
|
|
|
523
578
|
*/
|
|
524
579
|
const RETRY_AFTER_SECONDS = 30;
|
|
525
580
|
|
|
526
|
-
/**
|
|
527
|
-
|
|
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
|
+
}
|
|
528
598
|
|
|
529
|
-
/** @param {number}
|
|
530
|
-
function
|
|
531
|
-
return
|
|
599
|
+
/** @param {number} ms */
|
|
600
|
+
function sleep(ms) {
|
|
601
|
+
return new Promise((resolve) => {
|
|
602
|
+
setTimeout(resolve, ms).unref?.();
|
|
603
|
+
});
|
|
532
604
|
}
|
|
533
605
|
|
|
606
|
+
|
|
534
607
|
/**
|
|
535
608
|
* Render sırasında upstream düştüyse çıktı eksik veri içeriyor demektir.
|
|
536
609
|
* Böyle bir HTML önbelleğe yazılmaz: sonraki istek yeniden dener.
|
|
@@ -548,8 +621,8 @@ function hasUpstreamFailures(pathname) {
|
|
|
548
621
|
const failures = getUpstreamFailures();
|
|
549
622
|
if (!failures.length) return false;
|
|
550
623
|
|
|
551
|
-
const transient = failures.filter((failure) =>
|
|
552
|
-
const permanent = failures.filter((failure) => !
|
|
624
|
+
const transient = failures.filter((failure) => isTransientStatus(failure.status));
|
|
625
|
+
const permanent = failures.filter((failure) => !isTransientStatus(failure.status));
|
|
553
626
|
|
|
554
627
|
if (permanent.length) {
|
|
555
628
|
console.warn(
|
|
@@ -570,7 +643,7 @@ function hasUpstreamFailures(pathname) {
|
|
|
570
643
|
* @returns {import('./upstream-tracking.js').UpstreamFailure[]}
|
|
571
644
|
*/
|
|
572
645
|
function transientUpstreamFailures() {
|
|
573
|
-
return getUpstreamFailures().filter((failure) =>
|
|
646
|
+
return getUpstreamFailures().filter((failure) => isTransientStatus(failure.status));
|
|
574
647
|
}
|
|
575
648
|
|
|
576
649
|
/**
|
|
@@ -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
|
/**
|