jskelet 0.6.3 → 0.6.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.
Files changed (154) hide show
  1. package/AGENTS.md +136 -136
  2. package/CHANGELOG.md +633 -620
  3. package/LICENSE +21 -21
  4. package/README.md +2 -0
  5. package/bin/jskelet.mjs +130 -130
  6. package/docs/01-baslangic.md +291 -291
  7. package/docs/02-mimari.md +310 -310
  8. package/docs/03-routing.md +515 -515
  9. package/docs/04-render-ve-sablonlar.md +700 -661
  10. package/docs/05-islands.md +486 -486
  11. package/docs/06-cache.md +1467 -1443
  12. package/docs/07-yapilandirma.md +1208 -1197
  13. package/docs/08-build.md +429 -429
  14. package/docs/09-dev-araclari.md +364 -364
  15. package/docs/10-dagitim.md +351 -338
  16. package/docs/12-panel-ve-oturum.md +479 -478
  17. package/docs/README.md +83 -83
  18. package/docs/en/01-getting-started.md +298 -298
  19. package/docs/en/02-architecture.md +329 -329
  20. package/docs/en/03-routing.md +531 -531
  21. package/docs/en/04-rendering.md +708 -669
  22. package/docs/en/05-islands.md +497 -497
  23. package/docs/en/06-caching.md +1476 -1453
  24. package/docs/en/07-configuration.md +1229 -1219
  25. package/docs/en/08-build.md +447 -447
  26. package/docs/en/09-dev-tools.md +373 -373
  27. package/docs/en/10-deployment.md +355 -340
  28. package/docs/en/11-migration.md +398 -398
  29. package/docs/en/12-dashboards-and-sessions.md +489 -488
  30. package/docs/en/README.md +87 -87
  31. package/package.json +137 -137
  32. package/src/build/ensure-build.mjs +19 -19
  33. package/src/build/paths.mjs +153 -153
  34. package/src/build/resolve-peer.mjs +36 -36
  35. package/src/build/tasks/client.mjs +349 -349
  36. package/src/build/tasks/css.mjs +235 -235
  37. package/src/build/tasks/fonts.mjs +146 -146
  38. package/src/build/tasks/icons.mjs +357 -357
  39. package/src/build/tasks/images.mjs +244 -244
  40. package/src/build/tasks/precompress.mjs +78 -78
  41. package/src/build/tasks/templates.mjs +20 -20
  42. package/src/client/admin/i18n.js +764 -764
  43. package/src/client/admin/login.html +74 -74
  44. package/src/client/admin/panel.css +809 -809
  45. package/src/client/admin/panel.html +495 -495
  46. package/src/client/admin/panel.js +1251 -1251
  47. package/src/client/devtools/report.html +185 -185
  48. package/src/client/devtools/report.js +745 -745
  49. package/src/client/devtools/seo.js +628 -628
  50. package/src/client/dom.js +95 -95
  51. package/src/client/form.js +192 -192
  52. package/src/client/index.js +45 -45
  53. package/src/client/registry.js +305 -305
  54. package/src/client/safe-image.js +91 -91
  55. package/src/client/shared-cookie.js +225 -225
  56. package/src/client/store.js +36 -36
  57. package/src/client/swap.js +188 -188
  58. package/src/compile/codegen.js +336 -336
  59. package/src/compile/compile-all.js +149 -149
  60. package/src/compile/errors.js +66 -66
  61. package/src/compile/expr.js +409 -409
  62. package/src/compile/index.js +17 -17
  63. package/src/compile/parse.js +541 -541
  64. package/src/compile/resolve.js +211 -211
  65. package/src/compile/scan-exports.js +51 -51
  66. package/src/config/defaults.js +541 -534
  67. package/src/config/index.js +1500 -1469
  68. package/src/config/pattern.js +107 -107
  69. package/src/generate.mjs +163 -163
  70. package/src/http/control-flow.js +71 -71
  71. package/src/http/cookies-entry.js +21 -21
  72. package/src/http/cookies.js +277 -277
  73. package/src/http/request-cache.js +46 -46
  74. package/src/http/request-context.js +165 -165
  75. package/src/http/shared-cookie.js +178 -178
  76. package/src/index.js +101 -101
  77. package/src/init.mjs +232 -230
  78. package/src/migrate/apply.mjs +262 -262
  79. package/src/migrate/babel.mjs +79 -79
  80. package/src/migrate/classify.mjs +155 -155
  81. package/src/migrate/config.mjs +126 -126
  82. package/src/migrate/fs-walk.mjs +191 -191
  83. package/src/migrate/parse.mjs +26 -26
  84. package/src/migrate/scan.mjs +177 -177
  85. package/src/migrate/transform/expr-source.mjs +168 -168
  86. package/src/migrate/transform/island.mjs +67 -67
  87. package/src/migrate/transform/jsx-to-component.mjs +302 -302
  88. package/src/migrate/transform/jsx-to-jsk.mjs +330 -330
  89. package/src/migrate/transform/page-split.mjs +435 -435
  90. package/src/migrate/write.mjs +81 -81
  91. package/src/migrate.mjs +171 -171
  92. package/src/runtime/alias-hooks.mjs +119 -119
  93. package/src/runtime/register.mjs +4 -4
  94. package/src/server/admin/actions.js +229 -229
  95. package/src/server/admin/auth.js +125 -125
  96. package/src/server/admin/event-log.js +151 -151
  97. package/src/server/admin/gate.js +209 -209
  98. package/src/server/admin/inventory.js +188 -188
  99. package/src/server/admin/mount.js +56 -56
  100. package/src/server/admin/router.js +216 -216
  101. package/src/server/admin/snapshot.js +241 -241
  102. package/src/server/assets.js +147 -147
  103. package/src/server/auth/handoff.js +309 -309
  104. package/src/server/cache-blob.js +70 -70
  105. package/src/server/cache-control.js +45 -0
  106. package/src/server/cache-deps.js +42 -42
  107. package/src/server/cache-vary.js +113 -113
  108. package/src/server/cloudflare.js +607 -607
  109. package/src/server/create-app.js +366 -366
  110. package/src/server/data-cache.js +553 -553
  111. package/src/server/dev/report.js +485 -485
  112. package/src/server/dev/socket.js +170 -170
  113. package/src/server/dev/version-check.mjs +139 -139
  114. package/src/server/disk-cache.js +233 -233
  115. package/src/server/ejs-adapter.js +59 -59
  116. package/src/server/html-cache.js +1196 -1196
  117. package/src/server/image-optimizer.js +500 -500
  118. package/src/server/logs/access-middleware.js +66 -66
  119. package/src/server/logs/file-sink.js +193 -193
  120. package/src/server/logs/pipeline.js +165 -165
  121. package/src/server/logs/s3-put.js +214 -214
  122. package/src/server/logs/s3-sink.js +112 -112
  123. package/src/server/metadata.js +102 -102
  124. package/src/server/middleware/compression.js +205 -205
  125. package/src/server/middleware/csrf.js +134 -134
  126. package/src/server/middleware/dev-gate.js +75 -75
  127. package/src/server/middleware/headers.js +37 -37
  128. package/src/server/middleware/redirects.js +32 -32
  129. package/src/server/middleware/robots-txt.js +341 -341
  130. package/src/server/middleware/static-precompressed.js +121 -121
  131. package/src/server/middleware/trailing-slash.js +53 -53
  132. package/src/server/middleware/upstream-proxy.js +141 -141
  133. package/src/server/og-image.js +965 -356
  134. package/src/server/og-raster.mjs +21 -0
  135. package/src/server/port-guard.js +255 -255
  136. package/src/server/prewarm.js +1082 -1082
  137. package/src/server/redis.js +588 -588
  138. package/src/server/render.js +910 -910
  139. package/src/server/router.js +157 -157
  140. package/src/server/status-page.js +265 -265
  141. package/src/server/upstream-limiter.js +376 -376
  142. package/src/server/upstream-tracking.js +166 -166
  143. package/src/shared/cookie-domain.js +66 -66
  144. package/src/start.mjs +22 -22
  145. package/src/templates/layout.ejs +30 -30
  146. package/src/templates/layout.jsk +30 -30
  147. package/src/version.mjs +31 -31
  148. package/src/views/components/loader.js +101 -101
  149. package/src/views/helpers/html.js +102 -102
  150. package/src/views/helpers/tags.js +375 -375
  151. package/types/config/defaults.d.ts +6 -0
  152. package/types/config/index.d.ts +6 -0
  153. package/types/server/cache-control.d.ts +28 -0
  154. package/types/server/og-image.d.ts +51 -3
@@ -1,376 +1,376 @@
1
- /**
2
- * Upstream API'ye giden isteklerin host başına hız freni.
3
- *
4
- * Isıtma turundaki `rps` ayarı bu işi yapamıyordu: o, kendi sunucumuza atılan
5
- * **sayfa** isteklerini sayıyor. Bir sayfa render'ı bir API çağrısı da yapabilir
6
- * yirmi tane de; kotayı bağlayan şey sayfa sayısı değil, çağrı sayısı. Bu
7
- * yüzden fren `trackUpstreamFetch()` sarmalayıcısına, yani gerçek çağrının
8
- * geçtiği yere konuyor — ısıtma da, gerçek trafik de aynı bütçeden harcar.
9
- *
10
- * Üç mekanizma birlikte çalışıyor, çünkü üç farklı şeyi sınırlıyorlar:
11
- *
12
- * token bucket → ortalama hız (saniyedeki çağrı)
13
- * concurrency → anlık baskı (aynı anda uçan çağrı)
14
- * AIMD → doğru hızın ne olduğu
15
- *
16
- * Sabit bir hız her zaman ya çok yavaş ya çok hızlıdır: kotanın gerçek sınırını
17
- * kimse config'e doğru yazamaz, üstelik gün içinde değişir. 429 geldiğinde hızı
18
- * yarıya indirip (çarpımsal azalma) temiz geçen her pencerede bir adım geri
19
- * çıkmak (toplamsal artış), sınırı elle ayar yapılmadan bulur.
20
- *
21
- * Devre kesici de aynı gerekçeyle var: 429 geçici sayıldığı için o çağrıyla
22
- * üretilen HTML önbelleğe **yazılmıyor**. Yani rate limit'e girmiş bir turda
23
- * kota harcanır ve karşılığında hiçbir şey saklanmaz. Art arda 429 alan bir
24
- * host'a bir süre hiç gitmemek, o boşa yanmayı kesiyor.
25
- *
26
- * Varsayılan **kapalı**: `rate` verilmedikçe hiçbir istek beklemez. Framework
27
- * mevcut kurulumların davranışını sessizce değiştirmez.
28
- */
29
- import { DEFAULT_UPSTREAM_LIMIT } from "../config/defaults.js";
30
-
31
- /**
32
- * @typedef {object} HostState
33
- * @property {string} host
34
- * @property {number} maxRate Config'te verilen tavan; AIMD bunun üstüne çıkmaz.
35
- * @property {number} minRate Azalmanın dibi; 0'a inip tamamen kilitlenmesin.
36
- * @property {number} rate Saniyedeki izin — AIMD bunu oynatır.
37
- * @property {number} burst Kovanın boyu: kısa patlamalara verilen tolerans.
38
- * @property {number} concurrency Aynı anda uçabilecek çağrı sayısı.
39
- * @property {number} tokens
40
- * @property {number} refilledAt
41
- * @property {number} active Uçuştaki çağrı.
42
- * @property {(() => void)[]} waiters Boş yuva bekleyenler.
43
- * @property {Promise<void>} chain Admission sırası (FIFO).
44
- * @property {number} blockedUntil `Retry-After` boyunca kova tamamen durur.
45
- * @property {number} consecutiveFailures
46
- * @property {number} bypassUntil Devre kesicinin açık kaldığı an.
47
- * @property {number} adjustedAt Son AIMD kararının zamanı.
48
- * @property {number} throttled Kaç kez 429/503 görüldü (teşhis için).
49
- * @property {number} rejected Devre kesici kaç çağrıyı hiç göndermedi.
50
- */
51
-
52
- /** @type {Map<string, HostState>} */
53
- const hosts = new Map();
54
-
55
- /** @type {typeof DEFAULT_UPSTREAM_LIMIT} */
56
- let settings = { ...DEFAULT_UPSTREAM_LIMIT };
57
-
58
- /**
59
- * `createApp()` config yüklendikten sonra bir kez çağırır. Ayar değiştiğinde
60
- * host durumları sıfırlanır: eski `maxRate`'e göre ayarlanmış bir `rate`
61
- * yeni tavanın üstünde kalabilir.
62
- *
63
- * @param {Record<string, unknown> | undefined} config `cache().upstream`
64
- * @returns {void}
65
- */
66
- export function configureUpstreamLimiter(config) {
67
- settings = { ...DEFAULT_UPSTREAM_LIMIT, ...(config ?? {}) };
68
- hosts.clear();
69
- }
70
-
71
- /** @returns {boolean} */
72
- function enabled() {
73
- return settings.rate > 0 || hostOverrides().length > 0;
74
- }
75
-
76
- /** @returns {[string, Record<string, number>][]} */
77
- function hostOverrides() {
78
- const raw = /** @type {Record<string, any>} */ (settings.hosts ?? {});
79
- return Object.entries(raw);
80
- }
81
-
82
- /**
83
- * @param {string} url
84
- * @returns {string | null} Host, ya da URL çözülemediyse `null`.
85
- */
86
- function hostOf(url) {
87
- try {
88
- return new URL(url).host;
89
- } catch {
90
- return null;
91
- }
92
- }
93
-
94
- /**
95
- * @param {string} host
96
- * @returns {HostState}
97
- */
98
- function stateFor(host) {
99
- const existing = hosts.get(host);
100
- if (existing) return existing;
101
-
102
- const override = /** @type {Record<string, any>} */ (settings.hosts ?? {})[host] ?? {};
103
- const merged = { ...settings, ...override };
104
- const rate = num(merged.rate, 0);
105
-
106
- /** @type {HostState} */
107
- const created = {
108
- host,
109
- maxRate: rate,
110
- minRate: Math.min(num(merged.minRate, DEFAULT_UPSTREAM_LIMIT.minRate), rate || 1),
111
- rate: rate,
112
- // Kova boyu verilmezse bir saniyelik bütçe: hız 4/s ise dört çağrılık bir
113
- // patlama tolere edilir, beşincisi bekler.
114
- burst: num(merged.burst, 0) || Math.max(1, Math.ceil(rate)),
115
- concurrency: Math.floor(num(merged.concurrency, DEFAULT_UPSTREAM_LIMIT.concurrency)),
116
- tokens: num(merged.burst, 0) || Math.max(1, Math.ceil(rate)),
117
- refilledAt: Date.now(),
118
- active: 0,
119
- waiters: [],
120
- chain: Promise.resolve(),
121
- blockedUntil: 0,
122
- consecutiveFailures: 0,
123
- bypassUntil: 0,
124
- // 0, `Date.now()` değil: ilk saniye içinde gelen bir 429 de hızı
125
- // düşürmeli. Aksi hâlde ısıtma turunun ilk patlaması cezasız kalıyor.
126
- adjustedAt: 0,
127
- throttled: 0,
128
- rejected: 0,
129
- };
130
-
131
- hosts.set(host, created);
132
- return created;
133
- }
134
-
135
- /** @param {unknown} value @param {number} fallback */
136
- function num(value, fallback) {
137
- const parsed = Number(value);
138
- return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
139
- }
140
-
141
- /** @param {number} ms */
142
- function sleep(ms) {
143
- return new Promise((resolve) => {
144
- setTimeout(resolve, ms).unref?.();
145
- });
146
- }
147
-
148
- /**
149
- * Geçen süreye göre kovayı doldurur ve hata görülmeyen pencereler için hızı
150
- * bir adım yukarı çeker. İkisi aynı yerde: her ikisi de "zaman geçti" bilgisine
151
- * dayanıyor ve zamanlayıcı kurmadan, çağrı anında hesaplanıyorlar — boşta duran
152
- * bir süreç için sayaç işletmenin anlamı yok.
153
- *
154
- * @param {HostState} state
155
- * @returns {void}
156
- */
157
- function refill(state) {
158
- const now = Date.now();
159
- const elapsed = now - state.refilledAt;
160
-
161
- if (elapsed > 0) {
162
- state.tokens = Math.min(state.burst, state.tokens + (elapsed * state.rate) / 1000);
163
- state.refilledAt = now;
164
- }
165
-
166
- if (state.rate >= state.maxRate) return;
167
- if (now - state.adjustedAt < settings.increaseIntervalMs) return;
168
-
169
- // Toplamsal artış: azalma yarıya indiriyor, geri çıkış adım adım. Ters
170
- // olsaydı (hızlı çık, yavaş in) her pencerede yeniden 429 yerdik.
171
- state.rate = Math.min(state.maxRate, state.rate + num(settings.increaseStep, 1));
172
- state.adjustedAt = now;
173
- }
174
-
175
- /**
176
- * @param {HostState} state
177
- * @returns {number} Kaç ms sonra tekrar denenmeli; 0 → yuva hazır.
178
- */
179
- function waitFor(state) {
180
- const now = Date.now();
181
- if (state.blockedUntil > now) return state.blockedUntil - now;
182
-
183
- refill(state);
184
- if (state.tokens >= 1) return 0;
185
-
186
- // Bir tokenlik eksiğin dolması için gereken süre. `rate` düşükken bu
187
- // saniyeler olabilir; beklemek doğru davranış — alternatifi 429.
188
- return Math.max(10, Math.ceil(((1 - state.tokens) / state.rate) * 1000));
189
- }
190
-
191
- /**
192
- * Boş bir eşzamanlılık yuvası bekler.
193
- *
194
- * @param {HostState} state
195
- * @returns {Promise<void>}
196
- */
197
- function waitForSlot(state) {
198
- if (state.active < state.concurrency) return Promise.resolve();
199
- return new Promise((resolve) => state.waiters.push(resolve));
200
- }
201
-
202
- /**
203
- * Çağrı için izin alır. `null` dönerse fren kapalı ya da host çözülemedi;
204
- * `blocked` dönerse devre kesici açık ve çağrı hiç yapılmamalı.
205
- *
206
- * @param {string} url
207
- * @returns {Promise<{ blocked: boolean, host: string, release: () => void } | null>}
208
- */
209
- export async function limitUpstream(url) {
210
- if (!enabled()) return null;
211
-
212
- const host = hostOf(url);
213
- if (!host) return null;
214
-
215
- const state = stateFor(host);
216
- if (!state.maxRate) return null;
217
-
218
- if (state.bypassUntil > Date.now()) {
219
- state.rejected += 1;
220
- return { blocked: true, host, release: () => {} };
221
- }
222
-
223
- // Admission FIFO: her çağrı kendinden öncekinin izin almasını bekler. Sıra
224
- // olmadan, uyanan çağrılar rastgele yarışır ve ilk gelen en son geçebilir.
225
- const previous = state.chain;
226
- /** @type {() => void} */
227
- let releaseChain = () => {};
228
- state.chain = new Promise((resolve) => {
229
- releaseChain = resolve;
230
- });
231
-
232
- try {
233
- await previous;
234
-
235
- for (;;) {
236
- await waitForSlot(state);
237
- const wait = waitFor(state);
238
- if (wait === 0) break;
239
- await sleep(wait);
240
- }
241
-
242
- state.tokens -= 1;
243
- state.active += 1;
244
- } finally {
245
- releaseChain();
246
- }
247
-
248
- let released = false;
249
- return {
250
- blocked: false,
251
- host,
252
- release: () => {
253
- if (released) return;
254
- released = true;
255
- state.active -= 1;
256
- state.waiters.shift()?.();
257
- },
258
- };
259
- }
260
-
261
- /**
262
- * Yanıtın hıza etkisini işler. 429/503 hızı yarıya indirir ve `Retry-After`
263
- * varsa kovayı o süre boyunca tamamen durdurur; başarı sayaçları sıfırlar.
264
- *
265
- * @param {string} host
266
- * @param {number} status `0` → ağ hatası (yanıt gelmedi).
267
- * @param {string | null} [retryAfter] `Retry-After` başlığı.
268
- * @returns {void}
269
- */
270
- export function noteUpstreamResponse(host, status, retryAfter = null) {
271
- const state = hosts.get(host);
272
- if (!state) return;
273
-
274
- // Yalnızca "yavaşla" anlamına gelen cevaplar hızı cezalandırır. 400/404 bir
275
- // kota sorunu değil, 500 de öyle: onlar için yavaşlamak arızayı düzeltmez,
276
- // sadece siteyi yavaşlatır.
277
- if (status !== 429 && status !== 503) {
278
- state.consecutiveFailures = 0;
279
- return;
280
- }
281
-
282
- state.throttled += 1;
283
- state.consecutiveFailures += 1;
284
-
285
- const now = Date.now();
286
- const cooldown = retryAfterMs(retryAfter);
287
- if (cooldown) state.blockedUntil = Math.max(state.blockedUntil, now + cooldown);
288
-
289
- // Çarpımsal azalma, ama pencere başına bir kez: aynı anda uçan on çağrı
290
- // hep 429 dönerse hız on kez yarılanıp dibe vururdu.
291
- if (now - state.adjustedAt >= settings.decreaseIntervalMs) {
292
- state.rate = Math.max(state.minRate, state.rate / 2);
293
- state.adjustedAt = now;
294
- }
295
-
296
- if (
297
- state.consecutiveFailures >= settings.breakerFailures &&
298
- state.bypassUntil <= now
299
- ) {
300
- state.bypassUntil = now + settings.breakerCooldownMs;
301
- console.warn(
302
- `[upstream] ${state.host}: ${state.consecutiveFailures} consecutive rate limits — ` +
303
- `bypassing for ${settings.breakerCooldownMs}ms (rate is now ${state.rate.toFixed(1)}/s)`,
304
- );
305
- }
306
- }
307
-
308
- /**
309
- * `Retry-After` iki biçimde gelir: saniye ya da HTTP tarihi.
310
- *
311
- * @param {string | null | undefined} value
312
- * @returns {number} ms; okunamazsa 0.
313
- */
314
- function retryAfterMs(value) {
315
- if (!value) return 0;
316
-
317
- const seconds = Number(value);
318
- if (Number.isFinite(seconds) && seconds >= 0) return Math.min(seconds * 1000, 300_000);
319
-
320
- const date = Date.parse(value);
321
- if (Number.isNaN(date)) return 0;
322
-
323
- return Math.min(Math.max(0, date - Date.now()), 300_000);
324
- }
325
-
326
- /**
327
- * Dev paneli ve teşhis için host başına durum. Fren kapalıysa boş dizi.
328
- *
329
- * @returns {{ host: string, rate: number, maxRate: number, concurrency: number,
330
- * active: number, throttled: number, rejected: number, bypassed: boolean,
331
- * blockedMs: number, bypassedMs: number }[]}
332
- */
333
- export function getUpstreamLimiterStatus() {
334
- const now = Date.now();
335
-
336
- return [...hosts.values()].map((state) => ({
337
- host: state.host,
338
- rate: Number(state.rate.toFixed(2)),
339
- maxRate: state.maxRate,
340
- concurrency: state.concurrency,
341
- active: state.active,
342
- throttled: state.throttled,
343
- rejected: state.rejected,
344
- bypassed: state.bypassUntil > now,
345
- blockedMs: Math.max(0, state.blockedUntil - now),
346
- bypassedMs: Math.max(0, state.bypassUntil - now),
347
- }));
348
- }
349
-
350
- /**
351
- * Freni bekleten en uzun süre. Isıtma turunun tekrar denemesi bunu kullanıyor:
352
- * sabit bir bekleme, kesici 10 saniye açıkken 2 saniye sonra tekrar denemek
353
- * demekti — yani aynı 429'u peşin peşin almak.
354
- *
355
- * @returns {number} ms; fren kapalıysa ya da bekleyen bir şey yoksa 0.
356
- */
357
- export function upstreamCooldownMs() {
358
- const now = Date.now();
359
- let longest = 0;
360
-
361
- for (const state of hosts.values()) {
362
- longest = Math.max(longest, state.bypassUntil - now, state.blockedUntil - now);
363
- }
364
-
365
- return Math.max(0, longest);
366
- }
367
-
368
- /**
369
- * Testler için: ayarları verip tüm host durumlarını sıfırlar.
370
- *
371
- * @param {Record<string, unknown>} [config]
372
- * @returns {void}
373
- */
374
- export function resetUpstreamLimiterForTests(config = {}) {
375
- configureUpstreamLimiter(config);
376
- }
1
+ /**
2
+ * Upstream API'ye giden isteklerin host başına hız freni.
3
+ *
4
+ * Isıtma turundaki `rps` ayarı bu işi yapamıyordu: o, kendi sunucumuza atılan
5
+ * **sayfa** isteklerini sayıyor. Bir sayfa render'ı bir API çağrısı da yapabilir
6
+ * yirmi tane de; kotayı bağlayan şey sayfa sayısı değil, çağrı sayısı. Bu
7
+ * yüzden fren `trackUpstreamFetch()` sarmalayıcısına, yani gerçek çağrının
8
+ * geçtiği yere konuyor — ısıtma da, gerçek trafik de aynı bütçeden harcar.
9
+ *
10
+ * Üç mekanizma birlikte çalışıyor, çünkü üç farklı şeyi sınırlıyorlar:
11
+ *
12
+ * token bucket → ortalama hız (saniyedeki çağrı)
13
+ * concurrency → anlık baskı (aynı anda uçan çağrı)
14
+ * AIMD → doğru hızın ne olduğu
15
+ *
16
+ * Sabit bir hız her zaman ya çok yavaş ya çok hızlıdır: kotanın gerçek sınırını
17
+ * kimse config'e doğru yazamaz, üstelik gün içinde değişir. 429 geldiğinde hızı
18
+ * yarıya indirip (çarpımsal azalma) temiz geçen her pencerede bir adım geri
19
+ * çıkmak (toplamsal artış), sınırı elle ayar yapılmadan bulur.
20
+ *
21
+ * Devre kesici de aynı gerekçeyle var: 429 geçici sayıldığı için o çağrıyla
22
+ * üretilen HTML önbelleğe **yazılmıyor**. Yani rate limit'e girmiş bir turda
23
+ * kota harcanır ve karşılığında hiçbir şey saklanmaz. Art arda 429 alan bir
24
+ * host'a bir süre hiç gitmemek, o boşa yanmayı kesiyor.
25
+ *
26
+ * Varsayılan **kapalı**: `rate` verilmedikçe hiçbir istek beklemez. Framework
27
+ * mevcut kurulumların davranışını sessizce değiştirmez.
28
+ */
29
+ import { DEFAULT_UPSTREAM_LIMIT } from "../config/defaults.js";
30
+
31
+ /**
32
+ * @typedef {object} HostState
33
+ * @property {string} host
34
+ * @property {number} maxRate Config'te verilen tavan; AIMD bunun üstüne çıkmaz.
35
+ * @property {number} minRate Azalmanın dibi; 0'a inip tamamen kilitlenmesin.
36
+ * @property {number} rate Saniyedeki izin — AIMD bunu oynatır.
37
+ * @property {number} burst Kovanın boyu: kısa patlamalara verilen tolerans.
38
+ * @property {number} concurrency Aynı anda uçabilecek çağrı sayısı.
39
+ * @property {number} tokens
40
+ * @property {number} refilledAt
41
+ * @property {number} active Uçuştaki çağrı.
42
+ * @property {(() => void)[]} waiters Boş yuva bekleyenler.
43
+ * @property {Promise<void>} chain Admission sırası (FIFO).
44
+ * @property {number} blockedUntil `Retry-After` boyunca kova tamamen durur.
45
+ * @property {number} consecutiveFailures
46
+ * @property {number} bypassUntil Devre kesicinin açık kaldığı an.
47
+ * @property {number} adjustedAt Son AIMD kararının zamanı.
48
+ * @property {number} throttled Kaç kez 429/503 görüldü (teşhis için).
49
+ * @property {number} rejected Devre kesici kaç çağrıyı hiç göndermedi.
50
+ */
51
+
52
+ /** @type {Map<string, HostState>} */
53
+ const hosts = new Map();
54
+
55
+ /** @type {typeof DEFAULT_UPSTREAM_LIMIT} */
56
+ let settings = { ...DEFAULT_UPSTREAM_LIMIT };
57
+
58
+ /**
59
+ * `createApp()` config yüklendikten sonra bir kez çağırır. Ayar değiştiğinde
60
+ * host durumları sıfırlanır: eski `maxRate`'e göre ayarlanmış bir `rate`
61
+ * yeni tavanın üstünde kalabilir.
62
+ *
63
+ * @param {Record<string, unknown> | undefined} config `cache().upstream`
64
+ * @returns {void}
65
+ */
66
+ export function configureUpstreamLimiter(config) {
67
+ settings = { ...DEFAULT_UPSTREAM_LIMIT, ...(config ?? {}) };
68
+ hosts.clear();
69
+ }
70
+
71
+ /** @returns {boolean} */
72
+ function enabled() {
73
+ return settings.rate > 0 || hostOverrides().length > 0;
74
+ }
75
+
76
+ /** @returns {[string, Record<string, number>][]} */
77
+ function hostOverrides() {
78
+ const raw = /** @type {Record<string, any>} */ (settings.hosts ?? {});
79
+ return Object.entries(raw);
80
+ }
81
+
82
+ /**
83
+ * @param {string} url
84
+ * @returns {string | null} Host, ya da URL çözülemediyse `null`.
85
+ */
86
+ function hostOf(url) {
87
+ try {
88
+ return new URL(url).host;
89
+ } catch {
90
+ return null;
91
+ }
92
+ }
93
+
94
+ /**
95
+ * @param {string} host
96
+ * @returns {HostState}
97
+ */
98
+ function stateFor(host) {
99
+ const existing = hosts.get(host);
100
+ if (existing) return existing;
101
+
102
+ const override = /** @type {Record<string, any>} */ (settings.hosts ?? {})[host] ?? {};
103
+ const merged = { ...settings, ...override };
104
+ const rate = num(merged.rate, 0);
105
+
106
+ /** @type {HostState} */
107
+ const created = {
108
+ host,
109
+ maxRate: rate,
110
+ minRate: Math.min(num(merged.minRate, DEFAULT_UPSTREAM_LIMIT.minRate), rate || 1),
111
+ rate: rate,
112
+ // Kova boyu verilmezse bir saniyelik bütçe: hız 4/s ise dört çağrılık bir
113
+ // patlama tolere edilir, beşincisi bekler.
114
+ burst: num(merged.burst, 0) || Math.max(1, Math.ceil(rate)),
115
+ concurrency: Math.floor(num(merged.concurrency, DEFAULT_UPSTREAM_LIMIT.concurrency)),
116
+ tokens: num(merged.burst, 0) || Math.max(1, Math.ceil(rate)),
117
+ refilledAt: Date.now(),
118
+ active: 0,
119
+ waiters: [],
120
+ chain: Promise.resolve(),
121
+ blockedUntil: 0,
122
+ consecutiveFailures: 0,
123
+ bypassUntil: 0,
124
+ // 0, `Date.now()` değil: ilk saniye içinde gelen bir 429 de hızı
125
+ // düşürmeli. Aksi hâlde ısıtma turunun ilk patlaması cezasız kalıyor.
126
+ adjustedAt: 0,
127
+ throttled: 0,
128
+ rejected: 0,
129
+ };
130
+
131
+ hosts.set(host, created);
132
+ return created;
133
+ }
134
+
135
+ /** @param {unknown} value @param {number} fallback */
136
+ function num(value, fallback) {
137
+ const parsed = Number(value);
138
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
139
+ }
140
+
141
+ /** @param {number} ms */
142
+ function sleep(ms) {
143
+ return new Promise((resolve) => {
144
+ setTimeout(resolve, ms).unref?.();
145
+ });
146
+ }
147
+
148
+ /**
149
+ * Geçen süreye göre kovayı doldurur ve hata görülmeyen pencereler için hızı
150
+ * bir adım yukarı çeker. İkisi aynı yerde: her ikisi de "zaman geçti" bilgisine
151
+ * dayanıyor ve zamanlayıcı kurmadan, çağrı anında hesaplanıyorlar — boşta duran
152
+ * bir süreç için sayaç işletmenin anlamı yok.
153
+ *
154
+ * @param {HostState} state
155
+ * @returns {void}
156
+ */
157
+ function refill(state) {
158
+ const now = Date.now();
159
+ const elapsed = now - state.refilledAt;
160
+
161
+ if (elapsed > 0) {
162
+ state.tokens = Math.min(state.burst, state.tokens + (elapsed * state.rate) / 1000);
163
+ state.refilledAt = now;
164
+ }
165
+
166
+ if (state.rate >= state.maxRate) return;
167
+ if (now - state.adjustedAt < settings.increaseIntervalMs) return;
168
+
169
+ // Toplamsal artış: azalma yarıya indiriyor, geri çıkış adım adım. Ters
170
+ // olsaydı (hızlı çık, yavaş in) her pencerede yeniden 429 yerdik.
171
+ state.rate = Math.min(state.maxRate, state.rate + num(settings.increaseStep, 1));
172
+ state.adjustedAt = now;
173
+ }
174
+
175
+ /**
176
+ * @param {HostState} state
177
+ * @returns {number} Kaç ms sonra tekrar denenmeli; 0 → yuva hazır.
178
+ */
179
+ function waitFor(state) {
180
+ const now = Date.now();
181
+ if (state.blockedUntil > now) return state.blockedUntil - now;
182
+
183
+ refill(state);
184
+ if (state.tokens >= 1) return 0;
185
+
186
+ // Bir tokenlik eksiğin dolması için gereken süre. `rate` düşükken bu
187
+ // saniyeler olabilir; beklemek doğru davranış — alternatifi 429.
188
+ return Math.max(10, Math.ceil(((1 - state.tokens) / state.rate) * 1000));
189
+ }
190
+
191
+ /**
192
+ * Boş bir eşzamanlılık yuvası bekler.
193
+ *
194
+ * @param {HostState} state
195
+ * @returns {Promise<void>}
196
+ */
197
+ function waitForSlot(state) {
198
+ if (state.active < state.concurrency) return Promise.resolve();
199
+ return new Promise((resolve) => state.waiters.push(resolve));
200
+ }
201
+
202
+ /**
203
+ * Çağrı için izin alır. `null` dönerse fren kapalı ya da host çözülemedi;
204
+ * `blocked` dönerse devre kesici açık ve çağrı hiç yapılmamalı.
205
+ *
206
+ * @param {string} url
207
+ * @returns {Promise<{ blocked: boolean, host: string, release: () => void } | null>}
208
+ */
209
+ export async function limitUpstream(url) {
210
+ if (!enabled()) return null;
211
+
212
+ const host = hostOf(url);
213
+ if (!host) return null;
214
+
215
+ const state = stateFor(host);
216
+ if (!state.maxRate) return null;
217
+
218
+ if (state.bypassUntil > Date.now()) {
219
+ state.rejected += 1;
220
+ return { blocked: true, host, release: () => {} };
221
+ }
222
+
223
+ // Admission FIFO: her çağrı kendinden öncekinin izin almasını bekler. Sıra
224
+ // olmadan, uyanan çağrılar rastgele yarışır ve ilk gelen en son geçebilir.
225
+ const previous = state.chain;
226
+ /** @type {() => void} */
227
+ let releaseChain = () => {};
228
+ state.chain = new Promise((resolve) => {
229
+ releaseChain = resolve;
230
+ });
231
+
232
+ try {
233
+ await previous;
234
+
235
+ for (;;) {
236
+ await waitForSlot(state);
237
+ const wait = waitFor(state);
238
+ if (wait === 0) break;
239
+ await sleep(wait);
240
+ }
241
+
242
+ state.tokens -= 1;
243
+ state.active += 1;
244
+ } finally {
245
+ releaseChain();
246
+ }
247
+
248
+ let released = false;
249
+ return {
250
+ blocked: false,
251
+ host,
252
+ release: () => {
253
+ if (released) return;
254
+ released = true;
255
+ state.active -= 1;
256
+ state.waiters.shift()?.();
257
+ },
258
+ };
259
+ }
260
+
261
+ /**
262
+ * Yanıtın hıza etkisini işler. 429/503 hızı yarıya indirir ve `Retry-After`
263
+ * varsa kovayı o süre boyunca tamamen durdurur; başarı sayaçları sıfırlar.
264
+ *
265
+ * @param {string} host
266
+ * @param {number} status `0` → ağ hatası (yanıt gelmedi).
267
+ * @param {string | null} [retryAfter] `Retry-After` başlığı.
268
+ * @returns {void}
269
+ */
270
+ export function noteUpstreamResponse(host, status, retryAfter = null) {
271
+ const state = hosts.get(host);
272
+ if (!state) return;
273
+
274
+ // Yalnızca "yavaşla" anlamına gelen cevaplar hızı cezalandırır. 400/404 bir
275
+ // kota sorunu değil, 500 de öyle: onlar için yavaşlamak arızayı düzeltmez,
276
+ // sadece siteyi yavaşlatır.
277
+ if (status !== 429 && status !== 503) {
278
+ state.consecutiveFailures = 0;
279
+ return;
280
+ }
281
+
282
+ state.throttled += 1;
283
+ state.consecutiveFailures += 1;
284
+
285
+ const now = Date.now();
286
+ const cooldown = retryAfterMs(retryAfter);
287
+ if (cooldown) state.blockedUntil = Math.max(state.blockedUntil, now + cooldown);
288
+
289
+ // Çarpımsal azalma, ama pencere başına bir kez: aynı anda uçan on çağrı
290
+ // hep 429 dönerse hız on kez yarılanıp dibe vururdu.
291
+ if (now - state.adjustedAt >= settings.decreaseIntervalMs) {
292
+ state.rate = Math.max(state.minRate, state.rate / 2);
293
+ state.adjustedAt = now;
294
+ }
295
+
296
+ if (
297
+ state.consecutiveFailures >= settings.breakerFailures &&
298
+ state.bypassUntil <= now
299
+ ) {
300
+ state.bypassUntil = now + settings.breakerCooldownMs;
301
+ console.warn(
302
+ `[upstream] ${state.host}: ${state.consecutiveFailures} consecutive rate limits — ` +
303
+ `bypassing for ${settings.breakerCooldownMs}ms (rate is now ${state.rate.toFixed(1)}/s)`,
304
+ );
305
+ }
306
+ }
307
+
308
+ /**
309
+ * `Retry-After` iki biçimde gelir: saniye ya da HTTP tarihi.
310
+ *
311
+ * @param {string | null | undefined} value
312
+ * @returns {number} ms; okunamazsa 0.
313
+ */
314
+ function retryAfterMs(value) {
315
+ if (!value) return 0;
316
+
317
+ const seconds = Number(value);
318
+ if (Number.isFinite(seconds) && seconds >= 0) return Math.min(seconds * 1000, 300_000);
319
+
320
+ const date = Date.parse(value);
321
+ if (Number.isNaN(date)) return 0;
322
+
323
+ return Math.min(Math.max(0, date - Date.now()), 300_000);
324
+ }
325
+
326
+ /**
327
+ * Dev paneli ve teşhis için host başına durum. Fren kapalıysa boş dizi.
328
+ *
329
+ * @returns {{ host: string, rate: number, maxRate: number, concurrency: number,
330
+ * active: number, throttled: number, rejected: number, bypassed: boolean,
331
+ * blockedMs: number, bypassedMs: number }[]}
332
+ */
333
+ export function getUpstreamLimiterStatus() {
334
+ const now = Date.now();
335
+
336
+ return [...hosts.values()].map((state) => ({
337
+ host: state.host,
338
+ rate: Number(state.rate.toFixed(2)),
339
+ maxRate: state.maxRate,
340
+ concurrency: state.concurrency,
341
+ active: state.active,
342
+ throttled: state.throttled,
343
+ rejected: state.rejected,
344
+ bypassed: state.bypassUntil > now,
345
+ blockedMs: Math.max(0, state.blockedUntil - now),
346
+ bypassedMs: Math.max(0, state.bypassUntil - now),
347
+ }));
348
+ }
349
+
350
+ /**
351
+ * Freni bekleten en uzun süre. Isıtma turunun tekrar denemesi bunu kullanıyor:
352
+ * sabit bir bekleme, kesici 10 saniye açıkken 2 saniye sonra tekrar denemek
353
+ * demekti — yani aynı 429'u peşin peşin almak.
354
+ *
355
+ * @returns {number} ms; fren kapalıysa ya da bekleyen bir şey yoksa 0.
356
+ */
357
+ export function upstreamCooldownMs() {
358
+ const now = Date.now();
359
+ let longest = 0;
360
+
361
+ for (const state of hosts.values()) {
362
+ longest = Math.max(longest, state.bypassUntil - now, state.blockedUntil - now);
363
+ }
364
+
365
+ return Math.max(0, longest);
366
+ }
367
+
368
+ /**
369
+ * Testler için: ayarları verip tüm host durumlarını sıfırlar.
370
+ *
371
+ * @param {Record<string, unknown>} [config]
372
+ * @returns {void}
373
+ */
374
+ export function resetUpstreamLimiterForTests(config = {}) {
375
+ configureUpstreamLimiter(config);
376
+ }