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
|
@@ -704,6 +704,21 @@ const CSS = `
|
|
|
704
704
|
border-radius: 6px; padding: 1px 6px; font-family: ui-monospace, monospace;
|
|
705
705
|
}
|
|
706
706
|
|
|
707
|
+
/* Yüklü sürüm ve kayıt defterindeki son sürüm. */
|
|
708
|
+
.ver {
|
|
709
|
+
display: inline-flex; align-items: center; gap: 6px;
|
|
710
|
+
font-size: 10.5px; font-weight: 600; color: var(--muted);
|
|
711
|
+
border: 1px solid var(--line); border-radius: 999px; padding: 2px 8px;
|
|
712
|
+
font-family: ui-monospace, SFMono-Regular, monospace;
|
|
713
|
+
font-variant-numeric: tabular-nums;
|
|
714
|
+
}
|
|
715
|
+
.ver .cur { color: #dbe2ea; }
|
|
716
|
+
.ver .state { text-transform: uppercase; letter-spacing: .05em; font-size: 9.5px; }
|
|
717
|
+
.ver.latest .state { color: var(--good); }
|
|
718
|
+
.ver.outdated { border-color: rgba(251,191,36,.35); }
|
|
719
|
+
.ver.outdated .state { color: var(--mid); }
|
|
720
|
+
.ver .next { color: var(--mid); }
|
|
721
|
+
|
|
707
722
|
.rail {
|
|
708
723
|
display: flex; flex-direction: column; gap: 4px; padding: 14px 10px;
|
|
709
724
|
border-inline-end: 1px solid var(--line); background: rgba(0,0,0,.22);
|
|
@@ -893,6 +908,7 @@ const SKELETON = `
|
|
|
893
908
|
<div class="head">
|
|
894
909
|
<img src="${BASE}/logo.png" alt="">
|
|
895
910
|
<span class="title">JSkelet Dev</span>
|
|
911
|
+
<span class="ver" data-part="version" hidden></span>
|
|
896
912
|
<span class="pill"><span class="dot"></span><span data-part="pill"></span></span>
|
|
897
913
|
<span class="spacer"></span>
|
|
898
914
|
<span class="kbd">Alt+D</span>
|
|
@@ -1291,6 +1307,7 @@ function serverTab() {
|
|
|
1291
1307
|
]
|
|
1292
1308
|
: []),
|
|
1293
1309
|
])}
|
|
1310
|
+
${upstreamSection()}
|
|
1294
1311
|
<h4>Recent requests</h4>
|
|
1295
1312
|
${
|
|
1296
1313
|
requests.length
|
|
@@ -1309,6 +1326,39 @@ function serverTab() {
|
|
|
1309
1326
|
`;
|
|
1310
1327
|
}
|
|
1311
1328
|
|
|
1329
|
+
/**
|
|
1330
|
+
* Upstream hız freninin durumu. Fren kapalıysa (varsayılan) hiçbir şey
|
|
1331
|
+
* basılmaz; açıkken 429 fırtınasında hızın nereye indiğini görmek, ayarı
|
|
1332
|
+
* körlemesine yapmamanın tek yolu.
|
|
1333
|
+
*
|
|
1334
|
+
* @returns {string}
|
|
1335
|
+
*/
|
|
1336
|
+
function upstreamSection() {
|
|
1337
|
+
const hosts = serverStats?.upstream ?? [];
|
|
1338
|
+
if (!hosts.length) return "";
|
|
1339
|
+
|
|
1340
|
+
return `
|
|
1341
|
+
<h4>Upstream rate limit</h4>
|
|
1342
|
+
<div class="rows">${hosts
|
|
1343
|
+
.map((host) => {
|
|
1344
|
+
const throttled = host.rate < host.maxRate;
|
|
1345
|
+
const state = host.bypassed
|
|
1346
|
+
? "bypassed"
|
|
1347
|
+
: host.blockedMs
|
|
1348
|
+
? `waiting ${Math.ceil(host.blockedMs / 1000)} s`
|
|
1349
|
+
: `${host.active} in flight`;
|
|
1350
|
+
|
|
1351
|
+
return `<div class="row">
|
|
1352
|
+
<span class="status ${host.bypassed ? "bad" : throttled ? "warn" : ""}">${host.rate}/s</span>
|
|
1353
|
+
<span class="path">${escapeHtml(host.host)}</span>
|
|
1354
|
+
<span class="hint">${escapeHtml(state)}</span>
|
|
1355
|
+
<span class="${host.throttled ? "mid" : ""}">${host.throttled} × 429</span>
|
|
1356
|
+
</div>`;
|
|
1357
|
+
})
|
|
1358
|
+
.join("")}</div>
|
|
1359
|
+
`;
|
|
1360
|
+
}
|
|
1361
|
+
|
|
1312
1362
|
function warmTab() {
|
|
1313
1363
|
const warm = serverStats?.prewarm;
|
|
1314
1364
|
|
|
@@ -1647,6 +1697,40 @@ function paintPrewarm(root) {
|
|
|
1647
1697
|
if (!warm.active) setTimeout(render, WARM_LINGER_MS);
|
|
1648
1698
|
}
|
|
1649
1699
|
|
|
1700
|
+
/**
|
|
1701
|
+
* Başlıktaki sürüm göstergesi: solda yüklü sürüm, sağda kayıt defterindeki
|
|
1702
|
+
* durum. Kontrol kapalıysa ya da kayıt defterine ulaşılamadıysa (`latest`
|
|
1703
|
+
* boş) yalnızca yüklü sürüm yazılır; "bilinmiyor" etiketi gürültü olurdu.
|
|
1704
|
+
*
|
|
1705
|
+
* @param {ShadowRoot} root
|
|
1706
|
+
* @param {{ current: string, latest: string | null, outdated: boolean } | undefined} version
|
|
1707
|
+
*/
|
|
1708
|
+
function paintVersion(root, version) {
|
|
1709
|
+
const node = root.querySelector("[data-part='version']");
|
|
1710
|
+
|
|
1711
|
+
if (!version?.current) {
|
|
1712
|
+
node.hidden = true;
|
|
1713
|
+
return;
|
|
1714
|
+
}
|
|
1715
|
+
|
|
1716
|
+
const { current, latest, outdated } = version;
|
|
1717
|
+
node.hidden = false;
|
|
1718
|
+
node.className = `ver ${latest ? (outdated ? "outdated" : "latest") : ""}`;
|
|
1719
|
+
node.title = latest
|
|
1720
|
+
? outdated
|
|
1721
|
+
? `Installed ${current}, npm has ${latest}. Run: npm install jskelet@latest`
|
|
1722
|
+
: `Installed ${current} — up to date with npm`
|
|
1723
|
+
: `Installed ${current}. The npm registry could not be reached.`;
|
|
1724
|
+
|
|
1725
|
+
node.innerHTML =
|
|
1726
|
+
`<span class="cur">v${escapeHtml(current)}</span>` +
|
|
1727
|
+
(latest
|
|
1728
|
+
? outdated
|
|
1729
|
+
? `<span class="state">outdated</span><span class="next">→ v${escapeHtml(latest)}</span>`
|
|
1730
|
+
: `<span class="state">latest</span>`
|
|
1731
|
+
: "");
|
|
1732
|
+
}
|
|
1733
|
+
|
|
1650
1734
|
function paint() {
|
|
1651
1735
|
const root = ensureRoot();
|
|
1652
1736
|
|
|
@@ -1690,6 +1774,7 @@ function paint() {
|
|
|
1690
1774
|
chip.textContent = String(errorCount);
|
|
1691
1775
|
|
|
1692
1776
|
const version = serverStats?.version;
|
|
1777
|
+
paintVersion(root, version);
|
|
1693
1778
|
const versionChip = root.querySelector("[data-part='version-chip']");
|
|
1694
1779
|
versionChip.hidden = !version?.outdated;
|
|
1695
1780
|
if (version?.outdated) versionChip.title = `JSkelet ${version.latest} is available`;
|
|
@@ -228,6 +228,19 @@ function overview() {
|
|
|
228
228
|
tone: apiErrors ? "bad" : "good",
|
|
229
229
|
sub: apiErrors ? `${apiErrors} errors` : "no errors",
|
|
230
230
|
},
|
|
231
|
+
// Upstream kotasının nasıl harcandığı: okumaların kaçı önbellekten
|
|
232
|
+
// döndü. Oran düşükse `withDataCache` TTL'i kısa demektir.
|
|
233
|
+
{
|
|
234
|
+
label: "Data cache",
|
|
235
|
+
value: data.cache?.dataStats?.reads
|
|
236
|
+
? `${Math.round(data.cache.dataStats.hitRatio * 100)}%`
|
|
237
|
+
: "—",
|
|
238
|
+
ratio: data.cache?.dataStats?.hitRatio ?? null,
|
|
239
|
+
tone: "good",
|
|
240
|
+
sub: data.cache?.dataStats?.reads
|
|
241
|
+
? `${data.cache.dataStats.produced} upstream / ${data.cache.dataStats.reads} reads`
|
|
242
|
+
: "no reads yet",
|
|
243
|
+
},
|
|
231
244
|
{
|
|
232
245
|
label: "Prewarm",
|
|
233
246
|
value: data.prewarm?.total
|
package/src/config/defaults.js
CHANGED
|
@@ -120,6 +120,42 @@ export const DEFAULT_DATA_CACHE = {
|
|
|
120
120
|
staleFactor: 10,
|
|
121
121
|
};
|
|
122
122
|
|
|
123
|
+
/**
|
|
124
|
+
* Upstream API'ye giden isteklerin host başına hız freni.
|
|
125
|
+
*
|
|
126
|
+
* Varsayılan **kapalı** (`rate: 0`): fren, kotasını bilen bir uygulamanın
|
|
127
|
+
* bilinçli kararı. Açıldığında `rate` bir tavan olur ve gerçek hız 429
|
|
128
|
+
* cevaplarına göre kendini aşağı çeker (bkz. `src/server/upstream-limiter.js`).
|
|
129
|
+
*
|
|
130
|
+
* `hosts` ile tek tek uçlar ayrılabilir; API'lerin kotası aynı olmak zorunda
|
|
131
|
+
* değil:
|
|
132
|
+
*
|
|
133
|
+
* upstream: { rate: 10, hosts: { "api.example.com": { rate: 3 } } }
|
|
134
|
+
*/
|
|
135
|
+
export const DEFAULT_UPSTREAM_LIMIT = {
|
|
136
|
+
/** Saniyedeki en fazla çağrı. 0 → fren tamamen kapalı. */
|
|
137
|
+
rate: 0,
|
|
138
|
+
/** Kova boyu; verilmezse bir saniyelik bütçe kadar patlamaya izin verilir. */
|
|
139
|
+
burst: 0,
|
|
140
|
+
/** Aynı anda uçabilecek çağrı. Ortalama hızdan bağımsız: anlık baskıyı bağlar. */
|
|
141
|
+
concurrency: 8,
|
|
142
|
+
/** Azalmanın dibi: hız buranın altına inmez, yoksa site tamamen durur. */
|
|
143
|
+
minRate: 0.5,
|
|
144
|
+
/** Toplamsal artışın adımı (çağrı/saniye) ve periyodu. */
|
|
145
|
+
increaseStep: 1,
|
|
146
|
+
increaseIntervalMs: 5000,
|
|
147
|
+
/**
|
|
148
|
+
* İki azalma arasındaki en kısa süre. Aynı anda uçan on çağrının hepsi 429
|
|
149
|
+
* dönerse hız on kez yarılanıp dibe vurmasın.
|
|
150
|
+
*/
|
|
151
|
+
decreaseIntervalMs: 1000,
|
|
152
|
+
/** Devre kesici: art arda kaç 429'dan sonra host'a hiç gidilmeyeceği. */
|
|
153
|
+
breakerFailures: 5,
|
|
154
|
+
breakerCooldownMs: 10_000,
|
|
155
|
+
/** @type {Record<string, Record<string, number>>} */
|
|
156
|
+
hosts: {},
|
|
157
|
+
};
|
|
158
|
+
|
|
123
159
|
/**
|
|
124
160
|
* Opsiyonel Redis ikinci kademesi (L2).
|
|
125
161
|
*
|
|
@@ -155,8 +191,68 @@ export const DEFAULT_REDIS = {
|
|
|
155
191
|
commandTimeoutMs: 200,
|
|
156
192
|
};
|
|
157
193
|
|
|
194
|
+
/**
|
|
195
|
+
* Önbellek yönetim paneli.
|
|
196
|
+
*
|
|
197
|
+
* `enabled` varsayılan olarak **kapalı** ve ortama bakmaz: panel açıldığında
|
|
198
|
+
* production'da da çalışır, ama açılması bilinçli bir karar olmalı. Kapalıyken
|
|
199
|
+
* router hiç mount edilmez — yolun kendisi de yok, yani 404 dönen bir uç bile
|
|
200
|
+
* ortaya çıkmaz.
|
|
201
|
+
*
|
|
202
|
+
* Şifre her süreç başlangıcında yeniden üretilir (bkz.
|
|
203
|
+
* `src/server/cache-panel.js`): panelin ömrü sürecin ömrü kadardır ve bir
|
|
204
|
+
* deploy eski erişimi otomatik olarak iptal eder. Bu yüzden config'te şifre
|
|
205
|
+
* alanı yok.
|
|
206
|
+
*/
|
|
207
|
+
export const DEFAULT_CACHE_PANEL = {
|
|
208
|
+
enabled: false,
|
|
209
|
+
basePath: "/_jskelet/cache",
|
|
210
|
+
/** Kaç başarısız denemeden sonra IP yasaklanır. */
|
|
211
|
+
banAttempts: 3,
|
|
212
|
+
/** Yasağın süresi. */
|
|
213
|
+
banHours: 24,
|
|
214
|
+
/** Oturumun ömrü; süreç yeniden başladığında zaten sıfırlanır. */
|
|
215
|
+
sessionHours: 12,
|
|
216
|
+
};
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Cloudflare cache yüzeyi.
|
|
220
|
+
*
|
|
221
|
+
* JSkelet'in önbelleği origin önbelleği; ziyaretçinin gördüğü kopya CDN'de.
|
|
222
|
+
* Bu bölüm ikisini aynı panelden yönetilebilir kılar (bkz.
|
|
223
|
+
* `src/server/cloudflare.js`).
|
|
224
|
+
*
|
|
225
|
+
* Token **config'e yazılmamalı**: `JSKELET_CLOUDFLARE_KEY` env'i önceliklidir
|
|
226
|
+
* ve önerilen yol odur. Zone kimliği sır değil, ama o da env'den okunabilir
|
|
227
|
+
* (`JSKELET_CLOUDFLARE_ZONE_ID`).
|
|
228
|
+
*
|
|
229
|
+
* Gereken token izinleri: purge için `Zone.Cache Purge`, ayarlar için
|
|
230
|
+
* `Zone.Zone Settings`, analitik için `Zone.Analytics` (salt okunur).
|
|
231
|
+
*/
|
|
232
|
+
export const DEFAULT_CLOUDFLARE = {
|
|
233
|
+
/** `false` verilirse env'de token olsa bile yüzey kapalı kalır. */
|
|
234
|
+
enabled: true,
|
|
235
|
+
/** @type {string | null} */
|
|
236
|
+
zoneId: null,
|
|
237
|
+
/** @type {string | null} Env tercih edilir; burada tutmak sırrı repoya sokar. */
|
|
238
|
+
apiToken: null,
|
|
239
|
+
/**
|
|
240
|
+
* Purge, tam URL istiyor; panel elinde yalnızca yol tutuyor. Site adı
|
|
241
|
+
* verilmezse purge isteğinin geldiği istek origin'i kullanılır.
|
|
242
|
+
* @type {string | null}
|
|
243
|
+
*/
|
|
244
|
+
hostname: null,
|
|
245
|
+
/** Analitik penceresi (saat). Cloudflare'in izin verdiği aralıkla sınırlı. */
|
|
246
|
+
analyticsHours: 24,
|
|
247
|
+
};
|
|
248
|
+
|
|
158
249
|
/** Oturuma bağlı sayfalar ısıtılmaz; uygulama kendi listesini verebilir. */
|
|
159
|
-
export const DEFAULT_PREWARM_SKIP = [
|
|
250
|
+
export const DEFAULT_PREWARM_SKIP = [
|
|
251
|
+
"/api/",
|
|
252
|
+
"/_fragment/",
|
|
253
|
+
"/__jskelet/",
|
|
254
|
+
"/_jskelet/",
|
|
255
|
+
];
|
|
160
256
|
|
|
161
257
|
/**
|
|
162
258
|
* Site içi gezinme ipuçları (Speculation Rules + view transition).
|
|
@@ -182,7 +278,7 @@ export const DEFAULT_NAVIGATION = {
|
|
|
182
278
|
* hedefi olmayan uçların spekülatif istekle tetiklenmesi gerçek bir hata
|
|
183
279
|
* kaynağı; uygulama kendi listesini `navigation.exclude` ile ekler.
|
|
184
280
|
*/
|
|
185
|
-
export const DEFAULT_NAVIGATION_EXCLUDE = ["/api/*", "/_fragment/*"];
|
|
281
|
+
export const DEFAULT_NAVIGATION_EXCLUDE = ["/api/*", "/_fragment/*", "/_jskelet/*"];
|
|
186
282
|
|
|
187
283
|
/**
|
|
188
284
|
* Güvenlik ayarları.
|
package/src/config/index.js
CHANGED
|
@@ -15,8 +15,10 @@
|
|
|
15
15
|
* headers() → [{ source, headers: [{ key, value }] }]
|
|
16
16
|
* redirects() → [{ source, destination, permanent?, statusCode? }]
|
|
17
17
|
* rewrites() → [{ source, destination }] | { beforeFiles?, afterFiles? }
|
|
18
|
-
* cache() → { html?: { [source]: saniye },
|
|
19
|
-
*
|
|
18
|
+
* cache() → { html?: { [source]: saniye },
|
|
19
|
+
* query?: { [source]: string[] | true }, maxEntries?: number,
|
|
20
|
+
* data?: {...}, redis?: {...}, prewarm?: {...},
|
|
21
|
+
* panel?: {...} }
|
|
20
22
|
*
|
|
21
23
|
* Fonksiyon olmayan bölümler (`brand`, `security`, `static`, `navigation`…)
|
|
22
24
|
* düz nesne olarak okunur.
|
|
@@ -28,6 +30,8 @@ import { pathToFileURL } from "node:url";
|
|
|
28
30
|
import { compilePattern, matchPattern } from "./pattern.js";
|
|
29
31
|
import {
|
|
30
32
|
DEFAULT_BRAND,
|
|
33
|
+
DEFAULT_CACHE_PANEL,
|
|
34
|
+
DEFAULT_CLOUDFLARE,
|
|
31
35
|
DEFAULT_DATA_CACHE,
|
|
32
36
|
DEFAULT_DEV_GATE_BYPASS,
|
|
33
37
|
DEFAULT_DIRS,
|
|
@@ -40,6 +44,7 @@ import {
|
|
|
40
44
|
DEFAULT_SECURITY,
|
|
41
45
|
DEFAULT_STATIC,
|
|
42
46
|
DEFAULT_TRANSIENT_RETRY,
|
|
47
|
+
DEFAULT_UPSTREAM_LIMIT,
|
|
43
48
|
} from "./defaults.js";
|
|
44
49
|
|
|
45
50
|
/** Framework paketinin kökü — kendi şablonlarına ve varlıklarına erişir. */
|
|
@@ -81,12 +86,18 @@ const CONFIG_FILE = "jskelet.config.mjs";
|
|
|
81
86
|
* @property {{ pattern: CompiledPattern, destination: string, statusCode: number }[]} redirects
|
|
82
87
|
* @property {{ phase: "beforeFiles" | "afterFiles", pattern: CompiledPattern, destination: string }[]} rewrites
|
|
83
88
|
* @property {{ pattern: CompiledPattern, seconds: number }[]} html
|
|
89
|
+
* @property {{ pattern: CompiledPattern, allow: true | string[] }[]} cacheQuery
|
|
90
|
+
* Yol deseni başına, HTML cache anahtarına girmesine izin verilen query
|
|
91
|
+
* parametreleri. Eşleşen kural yoksa query'li istek cache'lenmez.
|
|
84
92
|
* @property {number} htmlMaxEntries HTML önbelleğinin girdi sınırı.
|
|
85
93
|
* @property {Record<string, unknown>} data Upstream veri önbelleği ayarları.
|
|
86
94
|
* @property {boolean} trackUpstream `fetch` sarılıp geçici hatalar otomatik bildirilsin mi.
|
|
87
95
|
* @property {boolean} trackDependencies Render'ın okuduğu veri anahtarları kaydedilsin mi.
|
|
88
96
|
* @property {{ attempts: number, delayMs: number }} transientRetry
|
|
89
97
|
* @property {RedisConfig} redis Opsiyonel Redis ikinci kademesi.
|
|
98
|
+
* @property {typeof DEFAULT_UPSTREAM_LIMIT} upstream Upstream hız freni.
|
|
99
|
+
* @property {typeof DEFAULT_CACHE_PANEL} cachePanel Önbellek yönetim paneli.
|
|
100
|
+
* @property {typeof DEFAULT_CLOUDFLARE} cloudflare Cloudflare cache yüzeyi.
|
|
90
101
|
* @property {Record<string, unknown>} prewarm
|
|
91
102
|
* @property {{ source: string, test: (pathname: string) => boolean }[]} prewarmPriority
|
|
92
103
|
* @property {Record<string, unknown>} brand
|
|
@@ -249,13 +260,171 @@ function normalizeRedis(raw) {
|
|
|
249
260
|
};
|
|
250
261
|
}
|
|
251
262
|
|
|
263
|
+
/**
|
|
264
|
+
* Upstream hız freni. Sayısal alanlar tipine zorlanır; bozuk bir değer freni
|
|
265
|
+
* yanlış ayarlamak yerine varsayılana döner.
|
|
266
|
+
*
|
|
267
|
+
* @param {unknown} raw
|
|
268
|
+
* @returns {typeof DEFAULT_UPSTREAM_LIMIT}
|
|
269
|
+
*/
|
|
270
|
+
function normalizeUpstream(raw) {
|
|
271
|
+
const source = /** @type {Record<string, any>} */ (raw ?? {});
|
|
272
|
+
const merged = { ...DEFAULT_UPSTREAM_LIMIT, ...source };
|
|
273
|
+
|
|
274
|
+
/** @param {string} key */
|
|
275
|
+
const positive = (key) => {
|
|
276
|
+
const value = Number(merged[key]);
|
|
277
|
+
return Number.isFinite(value) && value >= 0
|
|
278
|
+
? value
|
|
279
|
+
: /** @type {any} */ (DEFAULT_UPSTREAM_LIMIT)[key];
|
|
280
|
+
};
|
|
281
|
+
|
|
282
|
+
/** @type {Record<string, Record<string, number>>} */
|
|
283
|
+
const hosts = {};
|
|
284
|
+
for (const [host, override] of Object.entries(merged.hosts ?? {})) {
|
|
285
|
+
if (override && typeof override === "object") hosts[host] = override;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
return {
|
|
289
|
+
...merged,
|
|
290
|
+
rate: positive("rate"),
|
|
291
|
+
burst: positive("burst"),
|
|
292
|
+
concurrency: Math.max(1, Math.floor(positive("concurrency"))),
|
|
293
|
+
minRate: positive("minRate"),
|
|
294
|
+
increaseStep: positive("increaseStep"),
|
|
295
|
+
increaseIntervalMs: positive("increaseIntervalMs"),
|
|
296
|
+
decreaseIntervalMs: positive("decreaseIntervalMs"),
|
|
297
|
+
breakerFailures: Math.floor(positive("breakerFailures")),
|
|
298
|
+
breakerCooldownMs: positive("breakerCooldownMs"),
|
|
299
|
+
hosts,
|
|
300
|
+
};
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* Önbellek panelinin bölümü.
|
|
305
|
+
*
|
|
306
|
+
* `enabled` yalnızca açıkça `true` verildiğinde ya da `JSKELET_CACHE_PANEL`
|
|
307
|
+
* ortam değişkeni ayarlandığında açılır: paneli yanlışlıkla açmanın bedeli,
|
|
308
|
+
* önbelleği boşaltabilen bir ucu internete koymak.
|
|
309
|
+
*
|
|
310
|
+
* Ortam değişkeni config'in **üstünde** duruyor, çünkü paneli genelde bir
|
|
311
|
+
* arıza sırasında tek seferlik açmak isteniyor ve o an config dosyasını
|
|
312
|
+
* değiştirip yeniden dağıtmak istenmiyor. `JSKELET_CACHE_PANEL=0` aynı
|
|
313
|
+
* mantıkla config'te açık olan paneli kapatır.
|
|
314
|
+
*
|
|
315
|
+
* @param {unknown} raw
|
|
316
|
+
* @returns {typeof DEFAULT_CACHE_PANEL}
|
|
317
|
+
*/
|
|
318
|
+
function normalizeCachePanel(raw) {
|
|
319
|
+
const source = /** @type {Record<string, any>} */ (raw ?? {});
|
|
320
|
+
const env = process.env.JSKELET_CACHE_PANEL;
|
|
321
|
+
|
|
322
|
+
const basePath =
|
|
323
|
+
typeof source.basePath === "string" && source.basePath.startsWith("/")
|
|
324
|
+
? source.basePath.replace(/\/+$/, "")
|
|
325
|
+
: DEFAULT_CACHE_PANEL.basePath;
|
|
326
|
+
|
|
327
|
+
/** @param {string} key @param {number} min */
|
|
328
|
+
const positive = (key, min) => {
|
|
329
|
+
const value = Number(source[key]);
|
|
330
|
+
return Number.isFinite(value) && value >= min
|
|
331
|
+
? value
|
|
332
|
+
: /** @type {any} */ (DEFAULT_CACHE_PANEL)[key];
|
|
333
|
+
};
|
|
334
|
+
|
|
335
|
+
return {
|
|
336
|
+
enabled:
|
|
337
|
+
env === undefined
|
|
338
|
+
? source.enabled === true
|
|
339
|
+
: env !== "0" && env !== "false" && env !== "",
|
|
340
|
+
basePath: basePath || DEFAULT_CACHE_PANEL.basePath,
|
|
341
|
+
banAttempts: Math.floor(positive("banAttempts", 1)),
|
|
342
|
+
banHours: positive("banHours", 0),
|
|
343
|
+
sessionHours: positive("sessionHours", 0),
|
|
344
|
+
};
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/**
|
|
348
|
+
* Cloudflare bölümü. Token burada da verilebiliyor ama önerilen yol env;
|
|
349
|
+
* normalizasyon sadece tipleri sabitler, sırrı okumak `cloudflare.js`'in işi.
|
|
350
|
+
*
|
|
351
|
+
* @param {unknown} raw
|
|
352
|
+
* @returns {typeof DEFAULT_CLOUDFLARE}
|
|
353
|
+
*/
|
|
354
|
+
function normalizeCloudflare(raw) {
|
|
355
|
+
const source = /** @type {Record<string, any>} */ (raw ?? {});
|
|
356
|
+
const hours = Number(source.analyticsHours);
|
|
357
|
+
|
|
358
|
+
/** @param {unknown} value */
|
|
359
|
+
const text = (value) => (typeof value === "string" && value ? value : null);
|
|
360
|
+
|
|
361
|
+
return {
|
|
362
|
+
enabled: source.enabled !== false,
|
|
363
|
+
zoneId: text(source.zoneId),
|
|
364
|
+
apiToken: text(source.apiToken),
|
|
365
|
+
// Şema yazılırsa purge URL'i `https://https://…` olur; baştaki şema atılır.
|
|
366
|
+
hostname: text(source.hostname)?.replace(/^https?:\/\//, "") ?? null,
|
|
367
|
+
analyticsHours:
|
|
368
|
+
Number.isFinite(hours) && hours > 0
|
|
369
|
+
? Math.min(72, Math.floor(hours))
|
|
370
|
+
: DEFAULT_CLOUDFLARE.analyticsHours,
|
|
371
|
+
};
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/**
|
|
375
|
+
* `cache().query` → yol deseni başına, cache anahtarına girmesine izin verilen
|
|
376
|
+
* query parametreleri.
|
|
377
|
+
*
|
|
378
|
+
* Varsayılan bilinçli olarak "query varsa sayfa dinamik": bir yolun bütün
|
|
379
|
+
* query varyantlarını cache'lemek, `?utm_source=…` gibi sonsuz sayıda anahtar
|
|
380
|
+
* üretip LRU'daki gerçek sayfaları dışarı atıyor. Hangi parametrenin çıktıyı
|
|
381
|
+
* gerçekten değiştirdiğini yalnızca uygulama bilir, o yüzden izin listesi
|
|
382
|
+
* config'ten gelir.
|
|
383
|
+
*
|
|
384
|
+
* Bir desen `true` ile eşlenirse bütün parametreler anahtara girer (eski
|
|
385
|
+
* davranış), `[]` ile eşlenirse hiçbiri girmez — yani query yok sayılır ve
|
|
386
|
+
* bütün varyantlar query'siz sürümün HTML'ini paylaşır.
|
|
387
|
+
*
|
|
388
|
+
* @param {unknown} raw
|
|
389
|
+
* @returns {ResolvedConfig["cacheQuery"]}
|
|
390
|
+
*/
|
|
391
|
+
function normalizeQueryRules(raw) {
|
|
392
|
+
/** @type {ResolvedConfig["cacheQuery"]} */
|
|
393
|
+
const out = [];
|
|
394
|
+
|
|
395
|
+
for (const [source, value] of Object.entries(raw ?? {})) {
|
|
396
|
+
const pattern = compilePattern(source);
|
|
397
|
+
if (!pattern) continue;
|
|
398
|
+
|
|
399
|
+
if (value === true) {
|
|
400
|
+
out.push({ pattern, allow: true });
|
|
401
|
+
continue;
|
|
402
|
+
}
|
|
403
|
+
if (value === false) continue;
|
|
404
|
+
|
|
405
|
+
const allow = asArray(
|
|
406
|
+
typeof value === "string" ? [value] : value,
|
|
407
|
+
`cache().query["${source}"]`,
|
|
408
|
+
)
|
|
409
|
+
.filter((name) => typeof name === "string" && name)
|
|
410
|
+
.map(String);
|
|
411
|
+
out.push({ pattern, allow });
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
return out;
|
|
415
|
+
}
|
|
416
|
+
|
|
252
417
|
/**
|
|
253
418
|
* @param {unknown} raw
|
|
254
|
-
* @returns {{ html: ResolvedConfig["html"],
|
|
419
|
+
* @returns {{ html: ResolvedConfig["html"],
|
|
420
|
+
* cacheQuery: ResolvedConfig["cacheQuery"], htmlMaxEntries: number,
|
|
255
421
|
* data: Record<string, unknown>, trackUpstream: boolean,
|
|
256
422
|
* trackDependencies: boolean,
|
|
257
423
|
* transientRetry: { attempts: number, delayMs: number },
|
|
258
424
|
* redis: RedisConfig,
|
|
425
|
+
* upstream: typeof DEFAULT_UPSTREAM_LIMIT,
|
|
426
|
+
* cachePanel: typeof DEFAULT_CACHE_PANEL,
|
|
427
|
+
* cloudflare: typeof DEFAULT_CLOUDFLARE,
|
|
259
428
|
* prewarm: Record<string, unknown>,
|
|
260
429
|
* prewarmPriority: ResolvedConfig["prewarmPriority"] }}
|
|
261
430
|
*/
|
|
@@ -271,10 +440,12 @@ function normalizeCache(raw) {
|
|
|
271
440
|
}
|
|
272
441
|
|
|
273
442
|
const prewarm = { ...DEFAULT_PREWARM, ...(raw?.prewarm ?? {}) };
|
|
443
|
+
const queryRules = normalizeQueryRules(raw?.query);
|
|
274
444
|
const maxEntries = Number(raw?.maxEntries);
|
|
275
445
|
|
|
276
446
|
return {
|
|
277
447
|
html,
|
|
448
|
+
cacheQuery: queryRules,
|
|
278
449
|
htmlMaxEntries:
|
|
279
450
|
Number.isFinite(maxEntries) && maxEntries > 0
|
|
280
451
|
? Math.floor(maxEntries)
|
|
@@ -292,6 +463,9 @@ function normalizeCache(raw) {
|
|
|
292
463
|
? { attempts: 0, delayMs: 0 }
|
|
293
464
|
: { ...DEFAULT_TRANSIENT_RETRY, ...(raw?.transientRetry ?? {}) },
|
|
294
465
|
redis: normalizeRedis(raw?.redis),
|
|
466
|
+
upstream: normalizeUpstream(raw?.upstream),
|
|
467
|
+
cachePanel: normalizeCachePanel(raw?.panel),
|
|
468
|
+
cloudflare: normalizeCloudflare(raw?.cloudflare),
|
|
295
469
|
// Desenler derlenmiş hâlde ayrı alanda tutulur: `prewarm` sayısal
|
|
296
470
|
// ayarların düz torbası olarak kalsın, her turda yeniden derlenmesin.
|
|
297
471
|
prewarm,
|
|
@@ -504,12 +678,16 @@ export async function loadConfig(options = {}) {
|
|
|
504
678
|
|
|
505
679
|
const {
|
|
506
680
|
html,
|
|
681
|
+
cacheQuery,
|
|
507
682
|
htmlMaxEntries,
|
|
508
683
|
data,
|
|
509
684
|
trackUpstream,
|
|
510
685
|
trackDependencies,
|
|
511
686
|
transientRetry,
|
|
512
687
|
redis,
|
|
688
|
+
upstream,
|
|
689
|
+
cachePanel,
|
|
690
|
+
cloudflare,
|
|
513
691
|
prewarm,
|
|
514
692
|
prewarmPriority,
|
|
515
693
|
} = normalizeCache(cache);
|
|
@@ -524,12 +702,16 @@ export async function loadConfig(options = {}) {
|
|
|
524
702
|
redirects: normalizeRedirects(redirects),
|
|
525
703
|
rewrites: normalizeRewrites(rewrites),
|
|
526
704
|
html,
|
|
705
|
+
cacheQuery,
|
|
527
706
|
htmlMaxEntries,
|
|
528
707
|
data,
|
|
529
708
|
trackUpstream,
|
|
530
709
|
trackDependencies,
|
|
531
710
|
transientRetry,
|
|
532
711
|
redis,
|
|
712
|
+
upstream,
|
|
713
|
+
cachePanel,
|
|
714
|
+
cloudflare,
|
|
533
715
|
prewarm,
|
|
534
716
|
prewarmPriority,
|
|
535
717
|
brand,
|
package/src/index.js
CHANGED
|
@@ -41,6 +41,7 @@ export { headHints } from "./server/head-hints.js";
|
|
|
41
41
|
export { renderHeadMeta } from "./server/metadata.js";
|
|
42
42
|
export {
|
|
43
43
|
clearHtmlCache,
|
|
44
|
+
dropHtmlCacheKey,
|
|
44
45
|
getHtmlCacheEntries,
|
|
45
46
|
getHtmlCacheSize,
|
|
46
47
|
invalidateHtmlCache,
|
|
@@ -49,13 +50,32 @@ export {
|
|
|
49
50
|
export {
|
|
50
51
|
clearDataCache,
|
|
51
52
|
dataCache,
|
|
53
|
+
dropDataCacheKey,
|
|
52
54
|
getDataCacheEntries,
|
|
53
55
|
getDataCacheSize,
|
|
56
|
+
getDataCacheStats,
|
|
54
57
|
withDataCache,
|
|
55
58
|
} from "./server/data-cache.js";
|
|
56
59
|
// Paylaşımlı önbellek kademesinin durumu. Bağlantı kurulmamışken de güvenle
|
|
57
60
|
// çağrılır; healthcheck uçları bunu okuyor.
|
|
58
|
-
|
|
61
|
+
// `getRedisDetails()` bağlantının nereye kurulduğunu ve hangi türleri
|
|
62
|
+
// paylaştığını söyler (şifre asla dönmez); `inspectRedis()` tür başına anahtar
|
|
63
|
+
// sayar — bir `SCAN` turu olduğu için istek yolunda çağrılmamalı.
|
|
64
|
+
export { getRedisDetails, getRedisStatus, inspectRedis } from "./server/redis.js";
|
|
65
|
+
// CDN kademesi. JSkelet'in önbelleği origin önbelleği; ziyaretçinin gördüğü
|
|
66
|
+
// kopya edge'de duruyor. Bir içeriği gerçekten tazelemek için ikisini birlikte
|
|
67
|
+
// düşürmek gerekiyor: `invalidateHtmlCache()` + `purgeCloudflare()`.
|
|
68
|
+
export {
|
|
69
|
+
cloudflareConfigured,
|
|
70
|
+
fetchCacheAnalytics,
|
|
71
|
+
fetchCloudflareOverview,
|
|
72
|
+
fetchPathEdges,
|
|
73
|
+
getCloudflareStatus,
|
|
74
|
+
purgeCloudflare,
|
|
75
|
+
toCloudflareUrls,
|
|
76
|
+
} from "./server/cloudflare.js";
|
|
77
|
+
// Upstream hız freninin host başına durumu: healthcheck ve teşhis uçları için.
|
|
78
|
+
export { getUpstreamLimiterStatus } from "./server/upstream-limiter.js";
|
|
59
79
|
export { prewarm, prewarmProgress } from "./server/prewarm.js";
|
|
60
80
|
export { createProxy } from "./server/middleware/upstream-proxy.js";
|
|
61
81
|
export { getConfig, loadConfig } from "./config/index.js";
|
package/src/log.mjs
CHANGED
|
@@ -284,6 +284,31 @@ function truncate(text, max) {
|
|
|
284
284
|
/** Kutunun içi sabit genişlikte; uzun satırlar kırpılır. */
|
|
285
285
|
const BOX = 52;
|
|
286
286
|
|
|
287
|
+
/**
|
|
288
|
+
* Çerçeveli bilgi kutusu.
|
|
289
|
+
*
|
|
290
|
+
* Açılış logunda kaybolmaması gereken tek şey sır: önbellek panelinin şifresi
|
|
291
|
+
* her restart'ta değişiyor ve kullanıcı onu bir kez, akışın içinde görüyor.
|
|
292
|
+
* Genişlik içeriğe göre büyür — bir URL'i ya da 32 haneli bir şifreyi
|
|
293
|
+
* kırpmak kutunun bütün amacını bozar.
|
|
294
|
+
*
|
|
295
|
+
* @param {{ title: string, lines: string[],
|
|
296
|
+
* tint?: (value: string) => string }} info `lines` içinde boş string ayırıcı
|
|
297
|
+
* satır olur. Satırlar **düz metin** olmalı: bir ANSI dizisi `length`e
|
|
298
|
+
* sayıldığı için hizalamayı bozar, rengi çerçeve taşır.
|
|
299
|
+
*/
|
|
300
|
+
export function box({ title, lines, tint = c.cyan }) {
|
|
301
|
+
const width = Math.max(BOX, title.length + 4, ...lines.map((text) => text.length + 4));
|
|
302
|
+
const top = `┌─ ${title} ${"─".repeat(width - title.length - 3)}┐`;
|
|
303
|
+
const bottom = `└${"─".repeat(width)}┘`;
|
|
304
|
+
|
|
305
|
+
write(`\n${tint(top)}\n`);
|
|
306
|
+
for (const text of ["", ...lines, ""]) {
|
|
307
|
+
write(`${tint("│")} ${text}${" ".repeat(width - 4 - text.length)} ${tint("│")}\n`);
|
|
308
|
+
}
|
|
309
|
+
write(`${tint(bottom)}\n\n`);
|
|
310
|
+
}
|
|
311
|
+
|
|
287
312
|
/**
|
|
288
313
|
* Çerçeveli hata kutusu — kendi framework'ünü geliştirirken hatanın
|
|
289
314
|
* akış içinde kaybolmaması için.
|