jskelet 0.2.3 → 0.2.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 (91) hide show
  1. package/AGENTS.md +132 -132
  2. package/CHANGELOG.md +15 -0
  3. package/LICENSE +21 -21
  4. package/bin/jskelet.mjs +103 -103
  5. package/docs/01-baslangic.md +285 -285
  6. package/docs/02-mimari.md +287 -287
  7. package/docs/03-routing.md +480 -480
  8. package/docs/04-render-ve-sablonlar.md +490 -490
  9. package/docs/05-islands.md +482 -482
  10. package/docs/06-cache.md +1209 -1202
  11. package/docs/08-build.md +366 -366
  12. package/docs/09-dev-araclari.md +335 -335
  13. package/docs/10-dagitim.md +329 -329
  14. package/docs/12-panel-ve-oturum.md +384 -384
  15. package/docs/README.md +105 -105
  16. package/docs/en/01-getting-started.md +292 -292
  17. package/docs/en/02-architecture.md +305 -305
  18. package/docs/en/03-routing.md +497 -497
  19. package/docs/en/04-rendering.md +504 -504
  20. package/docs/en/05-islands.md +492 -492
  21. package/docs/en/06-caching.md +1239 -1232
  22. package/docs/en/07-configuration.md +986 -986
  23. package/docs/en/08-build.md +383 -383
  24. package/docs/en/09-dev-tools.md +342 -342
  25. package/docs/en/10-deployment.md +332 -332
  26. package/docs/en/11-migration.md +359 -359
  27. package/docs/en/12-dashboards-and-sessions.md +392 -392
  28. package/docs/en/README.md +112 -112
  29. package/package.json +102 -102
  30. package/src/build/ensure-build.mjs +15 -15
  31. package/src/build/paths.mjs +143 -143
  32. package/src/build/resolve-peer.mjs +36 -36
  33. package/src/build/tasks/client.mjs +268 -268
  34. package/src/build/tasks/css.mjs +124 -124
  35. package/src/build/tasks/fonts.mjs +146 -146
  36. package/src/build/tasks/icons.mjs +224 -224
  37. package/src/build/tasks/images.mjs +244 -244
  38. package/src/build/tasks/precompress.mjs +78 -78
  39. package/src/client/cache-panel/i18n.js +670 -0
  40. package/src/client/cache-panel/login.html +74 -71
  41. package/src/client/cache-panel/panel.css +756 -740
  42. package/src/client/cache-panel/panel.html +308 -307
  43. package/src/client/cache-panel/panel.js +915 -808
  44. package/src/client/devtools/report.html +185 -185
  45. package/src/client/devtools/report.js +725 -725
  46. package/src/client/dom.js +95 -95
  47. package/src/client/form.js +192 -192
  48. package/src/client/index.js +35 -35
  49. package/src/client/registry.js +297 -297
  50. package/src/client/safe-image.js +91 -91
  51. package/src/client/store.js +36 -36
  52. package/src/client/swap.js +188 -188
  53. package/src/config/pattern.js +107 -107
  54. package/src/http/control-flow.js +71 -71
  55. package/src/http/cookies.js +257 -257
  56. package/src/http/request-cache.js +46 -46
  57. package/src/http/request-context.js +162 -162
  58. package/src/index.js +83 -83
  59. package/src/init.mjs +221 -221
  60. package/src/runtime/alias-hooks.mjs +119 -119
  61. package/src/runtime/register.mjs +4 -4
  62. package/src/server/assets.js +147 -147
  63. package/src/server/cache-deps.js +42 -42
  64. package/src/server/cache-panel.js +759 -738
  65. package/src/server/cloudflare.js +607 -595
  66. package/src/server/create-app.js +291 -291
  67. package/src/server/data-cache.js +462 -462
  68. package/src/server/dev/report.js +369 -369
  69. package/src/server/dev/socket.js +170 -170
  70. package/src/server/dev/version-check.mjs +139 -139
  71. package/src/server/html-cache.js +817 -817
  72. package/src/server/metadata.js +102 -102
  73. package/src/server/middleware/compression.js +205 -205
  74. package/src/server/middleware/csrf.js +134 -134
  75. package/src/server/middleware/dev-gate.js +62 -62
  76. package/src/server/middleware/headers.js +37 -37
  77. package/src/server/middleware/redirects.js +32 -32
  78. package/src/server/middleware/static-precompressed.js +100 -100
  79. package/src/server/middleware/upstream-proxy.js +141 -141
  80. package/src/server/prewarm.js +601 -601
  81. package/src/server/redis.js +569 -569
  82. package/src/server/router.js +128 -128
  83. package/src/server/status-page.js +164 -164
  84. package/src/server/upstream-limiter.js +376 -376
  85. package/src/server/upstream-tracking.js +166 -166
  86. package/src/start.mjs +7 -7
  87. package/src/templates/layout.ejs +44 -44
  88. package/src/version.mjs +31 -31
  89. package/src/views/components/loader.js +85 -85
  90. package/src/views/helpers/html.js +102 -102
  91. package/src/views/helpers/tags.js +245 -245
@@ -1,601 +1,601 @@
1
- /**
2
- * Sunucu açılışında sayfaları önden render edip HTML cache'ini doldurur.
3
- *
4
- * Next'teki build-time prerender'ın karşılığı, ama çıktı diske yazılmaz:
5
- * HTML cache süreç belleğinde yaşadığı için ısıtma da süreç ayağa kalkınca
6
- * yapılır. Kazanç aynı — ilk ziyaretçi soğuk render'ı beklemez — fakat veri
7
- * dondurulmaz: her girdi route'un `revalidate` süresiyle yaşlanır ve
8
- * stale-while-revalidate ile arkada tazelenir.
9
- *
10
- * Isıtma gerçek HTTP istekleriyle yapılır: cache anahtarı, sıkıştırma ve
11
- * middleware zinciri normal trafikle bire bir aynı olsun. Hangi yolların
12
- * ısıtılacağını uygulama `hooks.prewarmPaths()` ile bildirir; genelde
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.
20
- */
21
- import process from "node:process";
22
- import { getConfig, hook } from "../config/index.js";
23
- import { getRequestContext } from "../http/request-context.js";
24
- import { takeInvalidatedPaths } from "./html-cache.js";
25
- import { getDataCacheStats } from "./data-cache.js";
26
- import { isTransientStatus } from "./upstream-tracking.js";
27
- import { upstreamCooldownMs } from "./upstream-limiter.js";
28
-
29
- /**
30
- * Isıtmanın canlı durumu. Dev araçları bunu okuyup ilerlemeyi gösterir;
31
- * üretimde kimse okumazsa da maliyeti bir nesnedir.
32
- */
33
- export const prewarmProgress = {
34
- active: false,
35
- done: 0,
36
- total: 0,
37
- ok: 0,
38
- failed: 0,
39
- /** @type {number | null} */
40
- startedAt: null,
41
- /** @type {number | null} */
42
- finishedAt: null,
43
- /**
44
- * Denenen her yolun sonucu; dev panelindeki Prewarming sekmesi bunu listeler.
45
- * @type {{ path: string, status: number, ms: number, bytes: number,
46
- * cache: string | null, error: string | null }[]}
47
- */
48
- entries: [],
49
- };
50
-
51
- /**
52
- * Isıtma turu sırasında bastırılan uyarılar: mesaj → kaç kez görüldü.
53
- *
54
- * Yüzlerce yolu tarayan bir tur, upstream bir an için tıksırdığında yüzlerce
55
- * satır loga döküyor ve asıl bilgi (kaç sayfa ısındı) kayboluyor. Tur boyunca
56
- * mesajlar burada toplanır, tur bitince tek satırda özetlenir. Yol adı
57
- * anahtara girmez: gruplanabilmesi için mesajın kendisi yeterli, tek bir yolun
58
- * ayrıntısı zaten `entries` üzerinden dev panelinde görünüyor.
59
- * @type {Map<string, number>}
60
- */
61
- const suppressed = new Map();
62
-
63
- /**
64
- * İstek ısıtma turunun kendi isteği mi? Yalnızca tur çalışırken ve istek
65
- * ısıtmanın user-agent'ıyla geldiğinde doğru; gerçek trafiğin hataları her
66
- * zaman loglanmaya devam eder.
67
- *
68
- * @param {{ get?: (name: string) => string | undefined } | null | undefined} req
69
- * @returns {boolean}
70
- */
71
- export function isPrewarmRequest(req) {
72
- if (!prewarmProgress.active || !req) return false;
73
- const ua = req.get?.("user-agent");
74
- return Boolean(ua) && ua === getConfig().brand.prewarmUserAgent;
75
- }
76
-
77
- /**
78
- * Şu an işlenen istek ısıtma turuna mı ait? `req` elde olmayan derin
79
- * katmanlar (render, cache) için: yanıt nesnesi istek bağlamında taşınıyor.
80
- *
81
- * @returns {boolean}
82
- */
83
- function inPrewarmRequest() {
84
- if (!prewarmProgress.active) return false;
85
- return isPrewarmRequest(getRequestContext()?.res?.req);
86
- }
87
-
88
- /**
89
- * Isıtma turuna ait bir uyarıyı loglamak yerine sayar.
90
- *
91
- * @param {string} message Gruplama anahtarı; yol adı içermemeli.
92
- * @returns {boolean} `true` ise sayıldı, çağıran taraf loglamamalı.
93
- */
94
- export function suppressForPrewarm(message) {
95
- if (!inPrewarmRequest()) return false;
96
- suppressed.set(message, (suppressed.get(message) ?? 0) + 1);
97
- return true;
98
- }
99
-
100
- /**
101
- * Bastırılan bir istek hatasını sayaca ekler. Yığın izi saklanmaz: özet
102
- * satırının amacı "neyin bozulduğunu" göstermek, hatayı ayıklamak değil.
103
- *
104
- * @param {number} status
105
- * @param {unknown} error
106
- * @returns {void}
107
- */
108
- export function notePrewarmError(status, error) {
109
- const message = error instanceof Error ? error.message : String(error);
110
- const key = `${status} ${message.split("\n")[0]}`;
111
- suppressed.set(key, (suppressed.get(key) ?? 0) + 1);
112
- }
113
-
114
- /** @param {unknown} value @param {number} fallback */
115
- function num(value, fallback) {
116
- const parsed = Number(value);
117
- return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
118
- }
119
-
120
- /**
121
- * Ayar sırası: ortam değişkeni → `jskelet.config.mjs` → kod varsayılanı.
122
- * Env önde, çünkü tek seferlik deneyler config'i düzenlemeden yapılabilsin.
123
- *
124
- * @param {string} envKey
125
- * @param {string} configKey
126
- * @param {number} fallback
127
- * @returns {number}
128
- */
129
- function setting(envKey, configKey, fallback) {
130
- return num(process.env[envKey], num(getConfig().prewarm?.[configKey], fallback));
131
- }
132
-
133
- /**
134
- * @returns {Promise<string[]>}
135
- */
136
- async function collectPaths() {
137
- const { prewarmSkip } = getConfig();
138
- const paths = await hook("prewarmPaths", []);
139
-
140
- if (!Array.isArray(paths)) {
141
- console.warn("[prewarm] hooks.prewarmPaths() must return an array, ignoring it");
142
- return [];
143
- }
144
-
145
- // Tekilleştirme sırayı korur: liste `PREWARM_MAX` ile budandığı için
146
- // uygulamanın verdiği öncelik sırası anlamlıdır.
147
- return [...new Set(paths)].filter(
148
- (candidate) =>
149
- typeof candidate === "string" &&
150
- candidate.startsWith("/") &&
151
- !prewarmSkip.some((prefix) => candidate.startsWith(prefix)),
152
- );
153
- }
154
-
155
- /**
156
- * `cache().prewarm.priority` desenlerine göre sıralar. Eşleşmeyen yollar
157
- * listenin sonuna, kendi aralarındaki sırayı koruyarak gider — uygulamanın
158
- * verdiği sıra hâlâ anlamlı olsun.
159
- *
160
- * @param {string[]} paths
161
- * @returns {{ head: string[], tail: string[] }}
162
- * `head` öncelikli yollar (her turda ısıtılır), `tail` geri kalan kuyruk
163
- * (turlar arasında dolaşılır).
164
- */
165
- function byPriority(paths) {
166
- const rules = getConfig().prewarmPriority;
167
- if (!rules.length) return { head: [], tail: paths };
168
-
169
- /** @type {string[][]} */
170
- const buckets = rules.map(() => []);
171
- /** @type {string[]} */
172
- const tail = [];
173
-
174
- for (const candidate of paths) {
175
- const rank = rules.findIndex((rule) => rule.test(candidate));
176
- if (rank === -1) tail.push(candidate);
177
- else buckets[rank].push(candidate);
178
- }
179
-
180
- return { head: buckets.flat(), tail };
181
- }
182
-
183
- /**
184
- * Kuyruğun kaldığı yer. Periyodik turlar listeyi baştan ısıtıp aynı ilk
185
- * `max` yolu tekrar tekrar tazelemesin: her tur bir sonraki dilimi alır ve
186
- * yeterli tur sonunda liste baştan sona ısınır.
187
- */
188
- let queueCursor = 0;
189
-
190
- /**
191
- * Bir turda ısıtılacak dilimi seçer: önce `priority` eşleşenler, sonra
192
- * kuyruğun sırası gelen parçası. Dışa açık olması bilinçli — sıralama ve
193
- * rotasyon, tur çalışmadan doğrulanabilen tek davranış.
194
- *
195
- * @param {string[]} all
196
- * @param {number} limit
197
- * @param {boolean} rotate
198
- * @returns {string[]}
199
- */
200
- export function selectPrewarmPaths(all, limit, rotate = true) {
201
- if (all.length <= limit) return all;
202
-
203
- const { head, tail } = byPriority(all);
204
- const selected = head.slice(0, limit);
205
- const room = limit - selected.length;
206
- if (room <= 0 || !tail.length) return selected;
207
-
208
- if (!rotate) return [...selected, ...tail.slice(0, room)];
209
-
210
- // Dilim listenin sonunu aşarsa başa sarar: kuyruk halkasal dolaşılır.
211
- const start = queueCursor % tail.length;
212
- const slice = tail.slice(start, start + room);
213
- if (slice.length < room) slice.push(...tail.slice(0, room - slice.length));
214
- queueCursor = (start + room) % tail.length;
215
-
216
- return [...selected, ...slice];
217
- }
218
-
219
- /** @param {number} ms */
220
- function sleep(ms) {
221
- return new Promise((resolve) => {
222
- setTimeout(resolve, ms).unref?.();
223
- });
224
- }
225
-
226
- /**
227
- * Saniyedeki istek sayısını sınırlar. Fren `concurrency`'den bağımsız
228
- * olmalı: paralellik gecikmeyi kapatmak için var, kotayı koruyan şey toplam
229
- * hız. İşçiler aynı sayacı paylaştığı için sıra kimde olursa olsun tur
230
- * verilen hızın üstüne çıkmaz.
231
- *
232
- * @param {number} rps 0 → sınırsız
233
- * @returns {() => Promise<void>}
234
- */
235
- function createPacer(rps) {
236
- if (!rps) return async () => {};
237
-
238
- const gap = 1000 / rps;
239
- let nextSlot = 0;
240
-
241
- return async () => {
242
- const now = Date.now();
243
- const slot = Math.max(now, nextSlot);
244
- nextSlot = slot + gap;
245
- if (slot > now) await sleep(slot - now);
246
- };
247
- }
248
-
249
- /**
250
- * `DEV_TOKEN` ayarlıyken `devGate` token taşımayan her isteğe 404 döner.
251
- * Isıtma kendi sunucusuna istek attığı için token'ı çerez olarak taşımalı;
252
- * yoksa tüm sayfalar 404 alır ve önbellek hiç dolmaz.
253
- *
254
- * @returns {Record<string, string>}
255
- */
256
- function devGateHeader() {
257
- const token = process.env.DEV_TOKEN;
258
- if (!token) return {};
259
-
260
- const cookie = getConfig().brand.devTokenCookie;
261
- return { cookie: `${cookie}=${encodeURIComponent(token)}` };
262
- }
263
-
264
- /**
265
- * @param {string} origin
266
- * @param {string[]} paths
267
- * @param {number} concurrency
268
- * @param {(ok: number, failed: number) => void} [report]
269
- * Tur ilerlemesini `prewarmProgress`'e yazar. Tekrar turunda sayaçların
270
- * anlamı değiştiği için çağıran taraf kendi formülünü verir.
271
- * @param {() => Promise<void>} [pace] İstek başına beklenen hız freni.
272
- * @returns {Promise<{ ok: number, failed: number,
273
- * failures: { path: string, status: number }[] }>}
274
- * `failures` durum koduyla birlikte döner: tekrar turuna yalnızca geçici
275
- * hatalar alınıyor, kalıcı olanı yeniden denemek boşa çağrı.
276
- */
277
- async function crawl(origin, paths, concurrency, report = undefined, pace = undefined) {
278
- const { brand } = getConfig();
279
- const cacheHeader = brand.cacheHeader.toLowerCase();
280
-
281
- let index = 0;
282
- let ok = 0;
283
- let failed = 0;
284
- /** @type {{ path: string, status: number }[]} */
285
- const failures = [];
286
-
287
- async function worker() {
288
- while (index < paths.length) {
289
- const target = paths[index];
290
- index += 1;
291
-
292
- if (pace) await pace();
293
-
294
- const startedAt = Date.now();
295
-
296
- try {
297
- const response = await fetch(`${origin}${target}`, {
298
- headers: {
299
- // Sıkıştırılmış gövde de cache'lensin.
300
- "accept-encoding": "br, gzip",
301
- "user-agent": brand.prewarmUserAgent,
302
- ...devGateHeader(),
303
- },
304
- });
305
- // Gövde okunmadan bağlantı açık kalır.
306
- const body = await response.arrayBuffer();
307
- if (response.ok) ok += 1;
308
- else {
309
- failed += 1;
310
- failures.push({ path: target, status: response.status });
311
- }
312
-
313
- prewarmProgress.entries.push({
314
- path: target,
315
- status: response.status,
316
- ms: Date.now() - startedAt,
317
- bytes: body.byteLength,
318
- cache: response.headers.get(cacheHeader),
319
- error: response.ok ? null : `HTTP ${response.status}`,
320
- });
321
- } catch (error) {
322
- failed += 1;
323
- // Yanıt hiç gelmedi: ağ hatası her zaman geçici, tekrar denenir.
324
- failures.push({ path: target, status: 0 });
325
- prewarmProgress.entries.push({
326
- path: target,
327
- status: 0,
328
- ms: Date.now() - startedAt,
329
- bytes: 0,
330
- cache: null,
331
- error: error instanceof Error ? error.message : String(error),
332
- });
333
- }
334
-
335
- if (report) {
336
- report(ok, failed);
337
- } else {
338
- prewarmProgress.done = ok + failed;
339
- prewarmProgress.ok = ok;
340
- prewarmProgress.failed = failed;
341
- }
342
- }
343
- }
344
-
345
- await Promise.all(
346
- Array.from({ length: Math.min(concurrency, paths.length) }, worker),
347
- );
348
-
349
- return { ok, failed, failures };
350
- }
351
-
352
- /**
353
- * Tekrar turuna girecek yollar. Yalnızca geçici hatalar: `400`/`403`/`404` gibi
354
- * deterministik cevaplar tekrar denemekle düzelmez ve o çağrılar kotadan
355
- * karşılıksız yiyor. Sınıflandırma render tarafıyla aynı listeden
356
- * (`isTransientStatus`), yoksa iki yer birbirinden kayar.
357
- *
358
- * @param {{ path: string, status: number }[]} failures
359
- * @returns {string[]}
360
- */
361
- function retryablePaths(failures) {
362
- return failures
363
- .filter((failure) => isTransientStatus(failure.status))
364
- .map((failure) => failure.path);
365
- }
366
-
367
- /**
368
- * Tekrar turundan önce beklenecek süre.
369
- *
370
- * Sabit bekleme yanlış soruyu cevaplıyordu: doğru süreyi upstream biliyor.
371
- * Hız freni açıkken `Retry-After` ya da devre kesicinin soğuma süresi zaten
372
- * elimizde; 10 saniye kapalı kalacak bir kesiciden 2 saniye sonra tekrar
373
- * denemek, aynı 429'u peşin peşin almak demek.
374
- *
375
- * @returns {number} ms
376
- */
377
- function retryDelayMs() {
378
- const configured = setting("PREWARM_RETRY_DELAY_MS", "retryDelayMs", 2000);
379
- const cooldown = upstreamCooldownMs();
380
-
381
- // Fren kapalıysa `cooldown` 0 olur ve davranış eskisi gibi kalır. Üst sınır
382
- // turun sonsuza kadar açık kalmasını engelliyor.
383
- return Math.min(Math.max(configured, cooldown), 60_000);
384
- }
385
-
386
- /**
387
- * Turun veri önbelleği üzerinden upstream'e ne kadar dokunduğunu özetler.
388
- *
389
- * @param {ReturnType<typeof getDataCacheStats>} before Tur başındaki sayaçlar.
390
- * @returns {string} Okunacak bir şey yoksa boş dize.
391
- */
392
- function upstreamUsage(before) {
393
- const after = getDataCacheStats();
394
- const reads = after.reads - before.reads;
395
- if (reads <= 0) return "";
396
-
397
- const produced = after.produced - before.produced;
398
- const coalesced = after.coalesced - before.coalesced;
399
- const shared = after.shared - before.shared;
400
- const served = reads - produced;
401
- const ratio = Math.round((served / reads) * 100);
402
-
403
- return (
404
- `${produced} upstream call${produced === 1 ? "" : "s"} for ${reads} data read${reads === 1 ? "" : "s"} ` +
405
- `(${ratio}% from the data cache` +
406
- `${coalesced ? `, ${coalesced} coalesced` : ""}` +
407
- `${shared ? `, ${shared} from the shared tier` : ""})`
408
- );
409
- }
410
-
411
- /**
412
- * @param {{ origin: string, quiet?: boolean, paths?: string[] }} options
413
- * `paths` verilirse hook çağrılmaz, yalnızca o yollar ısıtılır (dev
414
- * panelindeki "tekrar dene" bunu kullanır).
415
- * @returns {Promise<{ ok: number, failed: number, total: number, elapsed: number }>}
416
- */
417
- export async function prewarm({ origin, quiet = false, paths: only }) {
418
- const started = Date.now();
419
- const limit = setting("PREWARM_MAX", "max", 400);
420
- const isDev = process.env.NODE_ENV === "development";
421
-
422
- // Dev'de tek işçi: tarama, o an tarayıcıda açtığın sayfanın render'ıyla CPU
423
- // için yarışmasın.
424
- const concurrency = setting("PREWARM_CONCURRENCY", "concurrency", isDev ? 1 : 4);
425
-
426
- // Render tek bir olay döngüsünde çalışıyor: aralıksız bir tur, geliştirme
427
- // sırasında sayfa isteklerini ve dev panelinin kanalını arkasında bekletiyor.
428
- // Dev'de varsayılan bir hız freni bu yüzden var; üretimde ısıtma bir kez
429
- // olup bittiği için fren yalnızca istenirse (`prewarm.rps`) devreye girer.
430
- const rps = num(process.env.PREWARM_RPS, num(getConfig().prewarm?.rps, isDev ? 4 : 0));
431
- const pace = createPacer(rps);
432
-
433
- const all = only?.length ? only : await collectPaths();
434
- // Elle verilen liste budanmaz ve sıralanmaz: çağıran tam olarak neyi
435
- // istediğini biliyor (dev panelindeki "tekrar dene" bunu kullanır).
436
- const selected = only?.length
437
- ? all
438
- : selectPrewarmPaths(all, limit, getConfig().prewarm?.rotate !== false);
439
-
440
- // Invalidate edilmiş sayfalar kuyruğun önüne geçer: "içerik güncellendi"
441
- // bilgisi geldiğinde sayfa, ziyaretçi gelmesini beklemeden tazelenir. Bu
442
- // yollar `max` bütçesinin dışında tutulur — sayıları zaten gerçekleşen
443
- // invalidation kadar ve rotasyonun sırasını bozmaları istenmez.
444
- const pending = only?.length ? [] : takeInvalidatedPaths();
445
- const paths = pending.length
446
- ? [...new Set([...pending, ...selected])]
447
- : selected;
448
-
449
- Object.assign(prewarmProgress, {
450
- active: true,
451
- done: 0,
452
- total: paths.length,
453
- ok: 0,
454
- failed: 0,
455
- startedAt: started,
456
- finishedAt: null,
457
- entries: [],
458
- });
459
- suppressed.clear();
460
-
461
- // Kotanın gerçekten ne kadar harcandığı ancak veri önbelleğinden görülür:
462
- // 400 sayfalık bir tur, ortak bir uç için tek çağrı da yapabilir dört yüz de.
463
- const dataBefore = getDataCacheStats();
464
-
465
- let ok = 0;
466
- let failed = 0;
467
- let recovered = 0;
468
- let skippedRetry = 0;
469
- try {
470
- /** @type {{ path: string, status: number }[]} */
471
- let failures;
472
- ({ ok, failed, failures } = await crawl(
473
- origin,
474
- paths,
475
- concurrency,
476
- undefined,
477
- pace,
478
- ));
479
-
480
- // Hatalar çoğunlukla upstream rate limit'i (429): ilk tur yüzlerce sayfayı
481
- // aynı anda çekerken API'yi zorluyor. Tek seri tekrar turu bu sayfaların
482
- // önbelleğe girmesini sağlıyor; aksi hâlde ziyaretçi soğuk render'ı öder.
483
- const retryPaths = retryablePaths(failures);
484
- skippedRetry = failures.length - retryPaths.length;
485
-
486
- if (retryPaths.length) {
487
- const firstOk = ok;
488
- const firstFailed = failed;
489
-
490
- await sleep(retryDelayMs());
491
-
492
- const retry = await crawl(
493
- origin,
494
- retryPaths,
495
- 1,
496
- (retriedOk) => {
497
- // Tekrar turunda her başarı bir hatayı başarıya çevirir.
498
- prewarmProgress.ok = firstOk + retriedOk;
499
- prewarmProgress.failed = firstFailed - retriedOk;
500
- },
501
- pace,
502
- );
503
- recovered = retry.ok;
504
- ok += retry.ok;
505
- failed -= retry.ok;
506
- }
507
- } finally {
508
- prewarmProgress.active = false;
509
- prewarmProgress.finishedAt = Date.now();
510
- }
511
- const elapsed = Date.now() - started;
512
-
513
- if (!quiet && paths.length) {
514
- const skipped = all.length - selected.length;
515
- // Rotasyon açıkken sınırın dışında kalan yollar kaybolmuyor, bir sonraki
516
- // tura kalıyor; log bunu ayırt etmeli, yoksa "400 yol atlandı" satırı
517
- // hatalı bir kurulum sanılıyor.
518
- const rotate = !only?.length && getConfig().prewarm?.rotate !== false;
519
- console.log(
520
- `[prewarm] warmed ${ok}/${paths.length} pages` +
521
- `${pending.length ? `, ${pending.length} invalidated` : ""}` +
522
- `${failed ? `, ${failed} failed` : ""}` +
523
- `${recovered ? `, ${recovered} recovered on the retry pass` : ""}` +
524
- `${skippedRetry ? `, ${skippedRetry} not retried (permanent)` : ""}` +
525
- `${skipped > 0 ? `, ${skipped} ${rotate ? "deferred to the next pass" : "over the limit"}` : ""}` +
526
- ` (${(elapsed / 1000).toFixed(1)}s)`,
527
- );
528
-
529
- // Turun upstream'e ne kadar dokunduğu. Asıl karar bu satıra bakılarak
530
- // veriliyor: oran düşükse çözüm hız freni değil, `withDataCache` TTL'ini
531
- // tur aralığından uzun tutmak — fren çağrıları yavaşlatır, sayısını
532
- // azaltmaz.
533
- const upstreamCalls = upstreamUsage(dataBefore);
534
- if (upstreamCalls) console.log(`[prewarm] ${upstreamCalls}`);
535
-
536
- // Tur boyunca bastırılan hatalar: en sık görülenler önce, liste uzarsa
537
- // kalanı tek satırda toplanır. Amaç, logu şişirmeden "ne bozuldu"yu
538
- // görünür tutmak.
539
- if (suppressed.size) {
540
- const ranked = [...suppressed.entries()].sort((a, b) => b[1] - a[1]);
541
- const shown = ranked.slice(0, 5);
542
- const rest = ranked.slice(shown.length).reduce((sum, [, n]) => sum + n, 0);
543
- const total = ranked.reduce((sum, [, n]) => sum + n, 0);
544
-
545
- console.warn(
546
- `[prewarm] ${total} problem${total === 1 ? " was" : "s were"} not logged individually:\n` +
547
- shown.map(([message, n]) => ` ${n}× ${message}`).join("\n") +
548
- (rest ? `\n … ${rest} more in ${ranked.length - shown.length} other kinds` : ""),
549
- );
550
- }
551
- }
552
-
553
- return { ok, failed, total: paths.length, elapsed };
554
- }
555
-
556
- /**
557
- * Açılışta ısıtmayı tetikler. `listen` geri çağrısından çağrılır; isteğe
558
- * bağlı olarak periyodik tekrarlar. Hiçbir hata süreci düşürmez.
559
- *
560
- * @param {{ port: number }} options
561
- * @returns {void}
562
- */
563
- export function startPrewarm({ port }) {
564
- const config = getConfig();
565
- if (process.env.PREWARM === "0") return;
566
- if (process.env.PREWARM !== "1" && config.prewarm?.enabled === false) return;
567
- // Isıtacak yol bildirmeyen bir projede zamanlayıcı kurmanın anlamı yok.
568
- if (typeof config.hooks?.prewarmPaths !== "function") return;
569
-
570
- const isDev = process.env.NODE_ENV === "development";
571
- const origin = `http://127.0.0.1:${port}`;
572
-
573
- // Hız frenli bir tur `intervalSeconds`'tan uzun sürebilir; üst üste binen
574
- // turlar `prewarmProgress`'i bozar ve upstream'e iki kat yük bindirir.
575
- let running = false;
576
- const run = async () => {
577
- if (running) return;
578
- running = true;
579
- try {
580
- await prewarm({ origin });
581
- } catch (error) {
582
- console.error("[prewarm] failed", error);
583
- } finally {
584
- running = false;
585
- }
586
- };
587
-
588
- // Isıtma ilk isteklerle yarışmasın diye gecikmeyle başlar. Dev'de gecikme
589
- // daha uzun: dosya kaydı süreci yeniden başlattığı için zamanlayıcı da
590
- // ölür; yalnızca sunucu bir süre sakin kalınca ısınır.
591
- const delay = setting("PREWARM_DELAY_MS", "delayMs", isDev ? 3000 : 500);
592
- setTimeout(() => void run(), delay).unref();
593
-
594
- // Girdiler `revalidate` ile yaşlanır; stale-while-revalidate sayesinde
595
- // ziyaretçi beklemez. Periyodik tur, hiç ziyaret edilmeyen sayfaları da
596
- // sıcak tutmak isteyen kurulumlar için opsiyoneldir.
597
- // `rotate` ile birlikte bu ayar "damla damla ısıtma"ya dönüşür: her tur
598
- // kuyruğun bir dilimini alır, yeterli tur sonunda liste baştan sona ısınır.
599
- const interval = setting("PREWARM_INTERVAL_SECONDS", "intervalSeconds", 0);
600
- if (interval > 0) setInterval(() => void run(), interval * 1000).unref();
601
- }
1
+ /**
2
+ * Sunucu açılışında sayfaları önden render edip HTML cache'ini doldurur.
3
+ *
4
+ * Next'teki build-time prerender'ın karşılığı, ama çıktı diske yazılmaz:
5
+ * HTML cache süreç belleğinde yaşadığı için ısıtma da süreç ayağa kalkınca
6
+ * yapılır. Kazanç aynı — ilk ziyaretçi soğuk render'ı beklemez — fakat veri
7
+ * dondurulmaz: her girdi route'un `revalidate` süresiyle yaşlanır ve
8
+ * stale-while-revalidate ile arkada tazelenir.
9
+ *
10
+ * Isıtma gerçek HTTP istekleriyle yapılır: cache anahtarı, sıkıştırma ve
11
+ * middleware zinciri normal trafikle bire bir aynı olsun. Hangi yolların
12
+ * ısıtılacağını uygulama `hooks.prewarmPaths()` ile bildirir; genelde
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.
20
+ */
21
+ import process from "node:process";
22
+ import { getConfig, hook } from "../config/index.js";
23
+ import { getRequestContext } from "../http/request-context.js";
24
+ import { takeInvalidatedPaths } from "./html-cache.js";
25
+ import { getDataCacheStats } from "./data-cache.js";
26
+ import { isTransientStatus } from "./upstream-tracking.js";
27
+ import { upstreamCooldownMs } from "./upstream-limiter.js";
28
+
29
+ /**
30
+ * Isıtmanın canlı durumu. Dev araçları bunu okuyup ilerlemeyi gösterir;
31
+ * üretimde kimse okumazsa da maliyeti bir nesnedir.
32
+ */
33
+ export const prewarmProgress = {
34
+ active: false,
35
+ done: 0,
36
+ total: 0,
37
+ ok: 0,
38
+ failed: 0,
39
+ /** @type {number | null} */
40
+ startedAt: null,
41
+ /** @type {number | null} */
42
+ finishedAt: null,
43
+ /**
44
+ * Denenen her yolun sonucu; dev panelindeki Prewarming sekmesi bunu listeler.
45
+ * @type {{ path: string, status: number, ms: number, bytes: number,
46
+ * cache: string | null, error: string | null }[]}
47
+ */
48
+ entries: [],
49
+ };
50
+
51
+ /**
52
+ * Isıtma turu sırasında bastırılan uyarılar: mesaj → kaç kez görüldü.
53
+ *
54
+ * Yüzlerce yolu tarayan bir tur, upstream bir an için tıksırdığında yüzlerce
55
+ * satır loga döküyor ve asıl bilgi (kaç sayfa ısındı) kayboluyor. Tur boyunca
56
+ * mesajlar burada toplanır, tur bitince tek satırda özetlenir. Yol adı
57
+ * anahtara girmez: gruplanabilmesi için mesajın kendisi yeterli, tek bir yolun
58
+ * ayrıntısı zaten `entries` üzerinden dev panelinde görünüyor.
59
+ * @type {Map<string, number>}
60
+ */
61
+ const suppressed = new Map();
62
+
63
+ /**
64
+ * İstek ısıtma turunun kendi isteği mi? Yalnızca tur çalışırken ve istek
65
+ * ısıtmanın user-agent'ıyla geldiğinde doğru; gerçek trafiğin hataları her
66
+ * zaman loglanmaya devam eder.
67
+ *
68
+ * @param {{ get?: (name: string) => string | undefined } | null | undefined} req
69
+ * @returns {boolean}
70
+ */
71
+ export function isPrewarmRequest(req) {
72
+ if (!prewarmProgress.active || !req) return false;
73
+ const ua = req.get?.("user-agent");
74
+ return Boolean(ua) && ua === getConfig().brand.prewarmUserAgent;
75
+ }
76
+
77
+ /**
78
+ * Şu an işlenen istek ısıtma turuna mı ait? `req` elde olmayan derin
79
+ * katmanlar (render, cache) için: yanıt nesnesi istek bağlamında taşınıyor.
80
+ *
81
+ * @returns {boolean}
82
+ */
83
+ function inPrewarmRequest() {
84
+ if (!prewarmProgress.active) return false;
85
+ return isPrewarmRequest(getRequestContext()?.res?.req);
86
+ }
87
+
88
+ /**
89
+ * Isıtma turuna ait bir uyarıyı loglamak yerine sayar.
90
+ *
91
+ * @param {string} message Gruplama anahtarı; yol adı içermemeli.
92
+ * @returns {boolean} `true` ise sayıldı, çağıran taraf loglamamalı.
93
+ */
94
+ export function suppressForPrewarm(message) {
95
+ if (!inPrewarmRequest()) return false;
96
+ suppressed.set(message, (suppressed.get(message) ?? 0) + 1);
97
+ return true;
98
+ }
99
+
100
+ /**
101
+ * Bastırılan bir istek hatasını sayaca ekler. Yığın izi saklanmaz: özet
102
+ * satırının amacı "neyin bozulduğunu" göstermek, hatayı ayıklamak değil.
103
+ *
104
+ * @param {number} status
105
+ * @param {unknown} error
106
+ * @returns {void}
107
+ */
108
+ export function notePrewarmError(status, error) {
109
+ const message = error instanceof Error ? error.message : String(error);
110
+ const key = `${status} ${message.split("\n")[0]}`;
111
+ suppressed.set(key, (suppressed.get(key) ?? 0) + 1);
112
+ }
113
+
114
+ /** @param {unknown} value @param {number} fallback */
115
+ function num(value, fallback) {
116
+ const parsed = Number(value);
117
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
118
+ }
119
+
120
+ /**
121
+ * Ayar sırası: ortam değişkeni → `jskelet.config.mjs` → kod varsayılanı.
122
+ * Env önde, çünkü tek seferlik deneyler config'i düzenlemeden yapılabilsin.
123
+ *
124
+ * @param {string} envKey
125
+ * @param {string} configKey
126
+ * @param {number} fallback
127
+ * @returns {number}
128
+ */
129
+ function setting(envKey, configKey, fallback) {
130
+ return num(process.env[envKey], num(getConfig().prewarm?.[configKey], fallback));
131
+ }
132
+
133
+ /**
134
+ * @returns {Promise<string[]>}
135
+ */
136
+ async function collectPaths() {
137
+ const { prewarmSkip } = getConfig();
138
+ const paths = await hook("prewarmPaths", []);
139
+
140
+ if (!Array.isArray(paths)) {
141
+ console.warn("[prewarm] hooks.prewarmPaths() must return an array, ignoring it");
142
+ return [];
143
+ }
144
+
145
+ // Tekilleştirme sırayı korur: liste `PREWARM_MAX` ile budandığı için
146
+ // uygulamanın verdiği öncelik sırası anlamlıdır.
147
+ return [...new Set(paths)].filter(
148
+ (candidate) =>
149
+ typeof candidate === "string" &&
150
+ candidate.startsWith("/") &&
151
+ !prewarmSkip.some((prefix) => candidate.startsWith(prefix)),
152
+ );
153
+ }
154
+
155
+ /**
156
+ * `cache().prewarm.priority` desenlerine göre sıralar. Eşleşmeyen yollar
157
+ * listenin sonuna, kendi aralarındaki sırayı koruyarak gider — uygulamanın
158
+ * verdiği sıra hâlâ anlamlı olsun.
159
+ *
160
+ * @param {string[]} paths
161
+ * @returns {{ head: string[], tail: string[] }}
162
+ * `head` öncelikli yollar (her turda ısıtılır), `tail` geri kalan kuyruk
163
+ * (turlar arasında dolaşılır).
164
+ */
165
+ function byPriority(paths) {
166
+ const rules = getConfig().prewarmPriority;
167
+ if (!rules.length) return { head: [], tail: paths };
168
+
169
+ /** @type {string[][]} */
170
+ const buckets = rules.map(() => []);
171
+ /** @type {string[]} */
172
+ const tail = [];
173
+
174
+ for (const candidate of paths) {
175
+ const rank = rules.findIndex((rule) => rule.test(candidate));
176
+ if (rank === -1) tail.push(candidate);
177
+ else buckets[rank].push(candidate);
178
+ }
179
+
180
+ return { head: buckets.flat(), tail };
181
+ }
182
+
183
+ /**
184
+ * Kuyruğun kaldığı yer. Periyodik turlar listeyi baştan ısıtıp aynı ilk
185
+ * `max` yolu tekrar tekrar tazelemesin: her tur bir sonraki dilimi alır ve
186
+ * yeterli tur sonunda liste baştan sona ısınır.
187
+ */
188
+ let queueCursor = 0;
189
+
190
+ /**
191
+ * Bir turda ısıtılacak dilimi seçer: önce `priority` eşleşenler, sonra
192
+ * kuyruğun sırası gelen parçası. Dışa açık olması bilinçli — sıralama ve
193
+ * rotasyon, tur çalışmadan doğrulanabilen tek davranış.
194
+ *
195
+ * @param {string[]} all
196
+ * @param {number} limit
197
+ * @param {boolean} rotate
198
+ * @returns {string[]}
199
+ */
200
+ export function selectPrewarmPaths(all, limit, rotate = true) {
201
+ if (all.length <= limit) return all;
202
+
203
+ const { head, tail } = byPriority(all);
204
+ const selected = head.slice(0, limit);
205
+ const room = limit - selected.length;
206
+ if (room <= 0 || !tail.length) return selected;
207
+
208
+ if (!rotate) return [...selected, ...tail.slice(0, room)];
209
+
210
+ // Dilim listenin sonunu aşarsa başa sarar: kuyruk halkasal dolaşılır.
211
+ const start = queueCursor % tail.length;
212
+ const slice = tail.slice(start, start + room);
213
+ if (slice.length < room) slice.push(...tail.slice(0, room - slice.length));
214
+ queueCursor = (start + room) % tail.length;
215
+
216
+ return [...selected, ...slice];
217
+ }
218
+
219
+ /** @param {number} ms */
220
+ function sleep(ms) {
221
+ return new Promise((resolve) => {
222
+ setTimeout(resolve, ms).unref?.();
223
+ });
224
+ }
225
+
226
+ /**
227
+ * Saniyedeki istek sayısını sınırlar. Fren `concurrency`'den bağımsız
228
+ * olmalı: paralellik gecikmeyi kapatmak için var, kotayı koruyan şey toplam
229
+ * hız. İşçiler aynı sayacı paylaştığı için sıra kimde olursa olsun tur
230
+ * verilen hızın üstüne çıkmaz.
231
+ *
232
+ * @param {number} rps 0 → sınırsız
233
+ * @returns {() => Promise<void>}
234
+ */
235
+ function createPacer(rps) {
236
+ if (!rps) return async () => {};
237
+
238
+ const gap = 1000 / rps;
239
+ let nextSlot = 0;
240
+
241
+ return async () => {
242
+ const now = Date.now();
243
+ const slot = Math.max(now, nextSlot);
244
+ nextSlot = slot + gap;
245
+ if (slot > now) await sleep(slot - now);
246
+ };
247
+ }
248
+
249
+ /**
250
+ * `DEV_TOKEN` ayarlıyken `devGate` token taşımayan her isteğe 404 döner.
251
+ * Isıtma kendi sunucusuna istek attığı için token'ı çerez olarak taşımalı;
252
+ * yoksa tüm sayfalar 404 alır ve önbellek hiç dolmaz.
253
+ *
254
+ * @returns {Record<string, string>}
255
+ */
256
+ function devGateHeader() {
257
+ const token = process.env.DEV_TOKEN;
258
+ if (!token) return {};
259
+
260
+ const cookie = getConfig().brand.devTokenCookie;
261
+ return { cookie: `${cookie}=${encodeURIComponent(token)}` };
262
+ }
263
+
264
+ /**
265
+ * @param {string} origin
266
+ * @param {string[]} paths
267
+ * @param {number} concurrency
268
+ * @param {(ok: number, failed: number) => void} [report]
269
+ * Tur ilerlemesini `prewarmProgress`'e yazar. Tekrar turunda sayaçların
270
+ * anlamı değiştiği için çağıran taraf kendi formülünü verir.
271
+ * @param {() => Promise<void>} [pace] İstek başına beklenen hız freni.
272
+ * @returns {Promise<{ ok: number, failed: number,
273
+ * failures: { path: string, status: number }[] }>}
274
+ * `failures` durum koduyla birlikte döner: tekrar turuna yalnızca geçici
275
+ * hatalar alınıyor, kalıcı olanı yeniden denemek boşa çağrı.
276
+ */
277
+ async function crawl(origin, paths, concurrency, report = undefined, pace = undefined) {
278
+ const { brand } = getConfig();
279
+ const cacheHeader = brand.cacheHeader.toLowerCase();
280
+
281
+ let index = 0;
282
+ let ok = 0;
283
+ let failed = 0;
284
+ /** @type {{ path: string, status: number }[]} */
285
+ const failures = [];
286
+
287
+ async function worker() {
288
+ while (index < paths.length) {
289
+ const target = paths[index];
290
+ index += 1;
291
+
292
+ if (pace) await pace();
293
+
294
+ const startedAt = Date.now();
295
+
296
+ try {
297
+ const response = await fetch(`${origin}${target}`, {
298
+ headers: {
299
+ // Sıkıştırılmış gövde de cache'lensin.
300
+ "accept-encoding": "br, gzip",
301
+ "user-agent": brand.prewarmUserAgent,
302
+ ...devGateHeader(),
303
+ },
304
+ });
305
+ // Gövde okunmadan bağlantı açık kalır.
306
+ const body = await response.arrayBuffer();
307
+ if (response.ok) ok += 1;
308
+ else {
309
+ failed += 1;
310
+ failures.push({ path: target, status: response.status });
311
+ }
312
+
313
+ prewarmProgress.entries.push({
314
+ path: target,
315
+ status: response.status,
316
+ ms: Date.now() - startedAt,
317
+ bytes: body.byteLength,
318
+ cache: response.headers.get(cacheHeader),
319
+ error: response.ok ? null : `HTTP ${response.status}`,
320
+ });
321
+ } catch (error) {
322
+ failed += 1;
323
+ // Yanıt hiç gelmedi: ağ hatası her zaman geçici, tekrar denenir.
324
+ failures.push({ path: target, status: 0 });
325
+ prewarmProgress.entries.push({
326
+ path: target,
327
+ status: 0,
328
+ ms: Date.now() - startedAt,
329
+ bytes: 0,
330
+ cache: null,
331
+ error: error instanceof Error ? error.message : String(error),
332
+ });
333
+ }
334
+
335
+ if (report) {
336
+ report(ok, failed);
337
+ } else {
338
+ prewarmProgress.done = ok + failed;
339
+ prewarmProgress.ok = ok;
340
+ prewarmProgress.failed = failed;
341
+ }
342
+ }
343
+ }
344
+
345
+ await Promise.all(
346
+ Array.from({ length: Math.min(concurrency, paths.length) }, worker),
347
+ );
348
+
349
+ return { ok, failed, failures };
350
+ }
351
+
352
+ /**
353
+ * Tekrar turuna girecek yollar. Yalnızca geçici hatalar: `400`/`403`/`404` gibi
354
+ * deterministik cevaplar tekrar denemekle düzelmez ve o çağrılar kotadan
355
+ * karşılıksız yiyor. Sınıflandırma render tarafıyla aynı listeden
356
+ * (`isTransientStatus`), yoksa iki yer birbirinden kayar.
357
+ *
358
+ * @param {{ path: string, status: number }[]} failures
359
+ * @returns {string[]}
360
+ */
361
+ function retryablePaths(failures) {
362
+ return failures
363
+ .filter((failure) => isTransientStatus(failure.status))
364
+ .map((failure) => failure.path);
365
+ }
366
+
367
+ /**
368
+ * Tekrar turundan önce beklenecek süre.
369
+ *
370
+ * Sabit bekleme yanlış soruyu cevaplıyordu: doğru süreyi upstream biliyor.
371
+ * Hız freni açıkken `Retry-After` ya da devre kesicinin soğuma süresi zaten
372
+ * elimizde; 10 saniye kapalı kalacak bir kesiciden 2 saniye sonra tekrar
373
+ * denemek, aynı 429'u peşin peşin almak demek.
374
+ *
375
+ * @returns {number} ms
376
+ */
377
+ function retryDelayMs() {
378
+ const configured = setting("PREWARM_RETRY_DELAY_MS", "retryDelayMs", 2000);
379
+ const cooldown = upstreamCooldownMs();
380
+
381
+ // Fren kapalıysa `cooldown` 0 olur ve davranış eskisi gibi kalır. Üst sınır
382
+ // turun sonsuza kadar açık kalmasını engelliyor.
383
+ return Math.min(Math.max(configured, cooldown), 60_000);
384
+ }
385
+
386
+ /**
387
+ * Turun veri önbelleği üzerinden upstream'e ne kadar dokunduğunu özetler.
388
+ *
389
+ * @param {ReturnType<typeof getDataCacheStats>} before Tur başındaki sayaçlar.
390
+ * @returns {string} Okunacak bir şey yoksa boş dize.
391
+ */
392
+ function upstreamUsage(before) {
393
+ const after = getDataCacheStats();
394
+ const reads = after.reads - before.reads;
395
+ if (reads <= 0) return "";
396
+
397
+ const produced = after.produced - before.produced;
398
+ const coalesced = after.coalesced - before.coalesced;
399
+ const shared = after.shared - before.shared;
400
+ const served = reads - produced;
401
+ const ratio = Math.round((served / reads) * 100);
402
+
403
+ return (
404
+ `${produced} upstream call${produced === 1 ? "" : "s"} for ${reads} data read${reads === 1 ? "" : "s"} ` +
405
+ `(${ratio}% from the data cache` +
406
+ `${coalesced ? `, ${coalesced} coalesced` : ""}` +
407
+ `${shared ? `, ${shared} from the shared tier` : ""})`
408
+ );
409
+ }
410
+
411
+ /**
412
+ * @param {{ origin: string, quiet?: boolean, paths?: string[] }} options
413
+ * `paths` verilirse hook çağrılmaz, yalnızca o yollar ısıtılır (dev
414
+ * panelindeki "tekrar dene" bunu kullanır).
415
+ * @returns {Promise<{ ok: number, failed: number, total: number, elapsed: number }>}
416
+ */
417
+ export async function prewarm({ origin, quiet = false, paths: only }) {
418
+ const started = Date.now();
419
+ const limit = setting("PREWARM_MAX", "max", 400);
420
+ const isDev = process.env.NODE_ENV === "development";
421
+
422
+ // Dev'de tek işçi: tarama, o an tarayıcıda açtığın sayfanın render'ıyla CPU
423
+ // için yarışmasın.
424
+ const concurrency = setting("PREWARM_CONCURRENCY", "concurrency", isDev ? 1 : 4);
425
+
426
+ // Render tek bir olay döngüsünde çalışıyor: aralıksız bir tur, geliştirme
427
+ // sırasında sayfa isteklerini ve dev panelinin kanalını arkasında bekletiyor.
428
+ // Dev'de varsayılan bir hız freni bu yüzden var; üretimde ısıtma bir kez
429
+ // olup bittiği için fren yalnızca istenirse (`prewarm.rps`) devreye girer.
430
+ const rps = num(process.env.PREWARM_RPS, num(getConfig().prewarm?.rps, isDev ? 4 : 0));
431
+ const pace = createPacer(rps);
432
+
433
+ const all = only?.length ? only : await collectPaths();
434
+ // Elle verilen liste budanmaz ve sıralanmaz: çağıran tam olarak neyi
435
+ // istediğini biliyor (dev panelindeki "tekrar dene" bunu kullanır).
436
+ const selected = only?.length
437
+ ? all
438
+ : selectPrewarmPaths(all, limit, getConfig().prewarm?.rotate !== false);
439
+
440
+ // Invalidate edilmiş sayfalar kuyruğun önüne geçer: "içerik güncellendi"
441
+ // bilgisi geldiğinde sayfa, ziyaretçi gelmesini beklemeden tazelenir. Bu
442
+ // yollar `max` bütçesinin dışında tutulur — sayıları zaten gerçekleşen
443
+ // invalidation kadar ve rotasyonun sırasını bozmaları istenmez.
444
+ const pending = only?.length ? [] : takeInvalidatedPaths();
445
+ const paths = pending.length
446
+ ? [...new Set([...pending, ...selected])]
447
+ : selected;
448
+
449
+ Object.assign(prewarmProgress, {
450
+ active: true,
451
+ done: 0,
452
+ total: paths.length,
453
+ ok: 0,
454
+ failed: 0,
455
+ startedAt: started,
456
+ finishedAt: null,
457
+ entries: [],
458
+ });
459
+ suppressed.clear();
460
+
461
+ // Kotanın gerçekten ne kadar harcandığı ancak veri önbelleğinden görülür:
462
+ // 400 sayfalık bir tur, ortak bir uç için tek çağrı da yapabilir dört yüz de.
463
+ const dataBefore = getDataCacheStats();
464
+
465
+ let ok = 0;
466
+ let failed = 0;
467
+ let recovered = 0;
468
+ let skippedRetry = 0;
469
+ try {
470
+ /** @type {{ path: string, status: number }[]} */
471
+ let failures;
472
+ ({ ok, failed, failures } = await crawl(
473
+ origin,
474
+ paths,
475
+ concurrency,
476
+ undefined,
477
+ pace,
478
+ ));
479
+
480
+ // Hatalar çoğunlukla upstream rate limit'i (429): ilk tur yüzlerce sayfayı
481
+ // aynı anda çekerken API'yi zorluyor. Tek seri tekrar turu bu sayfaların
482
+ // önbelleğe girmesini sağlıyor; aksi hâlde ziyaretçi soğuk render'ı öder.
483
+ const retryPaths = retryablePaths(failures);
484
+ skippedRetry = failures.length - retryPaths.length;
485
+
486
+ if (retryPaths.length) {
487
+ const firstOk = ok;
488
+ const firstFailed = failed;
489
+
490
+ await sleep(retryDelayMs());
491
+
492
+ const retry = await crawl(
493
+ origin,
494
+ retryPaths,
495
+ 1,
496
+ (retriedOk) => {
497
+ // Tekrar turunda her başarı bir hatayı başarıya çevirir.
498
+ prewarmProgress.ok = firstOk + retriedOk;
499
+ prewarmProgress.failed = firstFailed - retriedOk;
500
+ },
501
+ pace,
502
+ );
503
+ recovered = retry.ok;
504
+ ok += retry.ok;
505
+ failed -= retry.ok;
506
+ }
507
+ } finally {
508
+ prewarmProgress.active = false;
509
+ prewarmProgress.finishedAt = Date.now();
510
+ }
511
+ const elapsed = Date.now() - started;
512
+
513
+ if (!quiet && paths.length) {
514
+ const skipped = all.length - selected.length;
515
+ // Rotasyon açıkken sınırın dışında kalan yollar kaybolmuyor, bir sonraki
516
+ // tura kalıyor; log bunu ayırt etmeli, yoksa "400 yol atlandı" satırı
517
+ // hatalı bir kurulum sanılıyor.
518
+ const rotate = !only?.length && getConfig().prewarm?.rotate !== false;
519
+ console.log(
520
+ `[prewarm] warmed ${ok}/${paths.length} pages` +
521
+ `${pending.length ? `, ${pending.length} invalidated` : ""}` +
522
+ `${failed ? `, ${failed} failed` : ""}` +
523
+ `${recovered ? `, ${recovered} recovered on the retry pass` : ""}` +
524
+ `${skippedRetry ? `, ${skippedRetry} not retried (permanent)` : ""}` +
525
+ `${skipped > 0 ? `, ${skipped} ${rotate ? "deferred to the next pass" : "over the limit"}` : ""}` +
526
+ ` (${(elapsed / 1000).toFixed(1)}s)`,
527
+ );
528
+
529
+ // Turun upstream'e ne kadar dokunduğu. Asıl karar bu satıra bakılarak
530
+ // veriliyor: oran düşükse çözüm hız freni değil, `withDataCache` TTL'ini
531
+ // tur aralığından uzun tutmak — fren çağrıları yavaşlatır, sayısını
532
+ // azaltmaz.
533
+ const upstreamCalls = upstreamUsage(dataBefore);
534
+ if (upstreamCalls) console.log(`[prewarm] ${upstreamCalls}`);
535
+
536
+ // Tur boyunca bastırılan hatalar: en sık görülenler önce, liste uzarsa
537
+ // kalanı tek satırda toplanır. Amaç, logu şişirmeden "ne bozuldu"yu
538
+ // görünür tutmak.
539
+ if (suppressed.size) {
540
+ const ranked = [...suppressed.entries()].sort((a, b) => b[1] - a[1]);
541
+ const shown = ranked.slice(0, 5);
542
+ const rest = ranked.slice(shown.length).reduce((sum, [, n]) => sum + n, 0);
543
+ const total = ranked.reduce((sum, [, n]) => sum + n, 0);
544
+
545
+ console.warn(
546
+ `[prewarm] ${total} problem${total === 1 ? " was" : "s were"} not logged individually:\n` +
547
+ shown.map(([message, n]) => ` ${n}× ${message}`).join("\n") +
548
+ (rest ? `\n … ${rest} more in ${ranked.length - shown.length} other kinds` : ""),
549
+ );
550
+ }
551
+ }
552
+
553
+ return { ok, failed, total: paths.length, elapsed };
554
+ }
555
+
556
+ /**
557
+ * Açılışta ısıtmayı tetikler. `listen` geri çağrısından çağrılır; isteğe
558
+ * bağlı olarak periyodik tekrarlar. Hiçbir hata süreci düşürmez.
559
+ *
560
+ * @param {{ port: number }} options
561
+ * @returns {void}
562
+ */
563
+ export function startPrewarm({ port }) {
564
+ const config = getConfig();
565
+ if (process.env.PREWARM === "0") return;
566
+ if (process.env.PREWARM !== "1" && config.prewarm?.enabled === false) return;
567
+ // Isıtacak yol bildirmeyen bir projede zamanlayıcı kurmanın anlamı yok.
568
+ if (typeof config.hooks?.prewarmPaths !== "function") return;
569
+
570
+ const isDev = process.env.NODE_ENV === "development";
571
+ const origin = `http://127.0.0.1:${port}`;
572
+
573
+ // Hız frenli bir tur `intervalSeconds`'tan uzun sürebilir; üst üste binen
574
+ // turlar `prewarmProgress`'i bozar ve upstream'e iki kat yük bindirir.
575
+ let running = false;
576
+ const run = async () => {
577
+ if (running) return;
578
+ running = true;
579
+ try {
580
+ await prewarm({ origin });
581
+ } catch (error) {
582
+ console.error("[prewarm] failed", error);
583
+ } finally {
584
+ running = false;
585
+ }
586
+ };
587
+
588
+ // Isıtma ilk isteklerle yarışmasın diye gecikmeyle başlar. Dev'de gecikme
589
+ // daha uzun: dosya kaydı süreci yeniden başlattığı için zamanlayıcı da
590
+ // ölür; yalnızca sunucu bir süre sakin kalınca ısınır.
591
+ const delay = setting("PREWARM_DELAY_MS", "delayMs", isDev ? 3000 : 500);
592
+ setTimeout(() => void run(), delay).unref();
593
+
594
+ // Girdiler `revalidate` ile yaşlanır; stale-while-revalidate sayesinde
595
+ // ziyaretçi beklemez. Periyodik tur, hiç ziyaret edilmeyen sayfaları da
596
+ // sıcak tutmak isteyen kurulumlar için opsiyoneldir.
597
+ // `rotate` ile birlikte bu ayar "damla damla ısıtma"ya dönüşür: her tur
598
+ // kuyruğun bir dilimini alır, yeterli tur sonunda liste baştan sona ısınır.
599
+ const interval = setting("PREWARM_INTERVAL_SECONDS", "intervalSeconds", 0);
600
+ if (interval > 0) setInterval(() => void run(), interval * 1000).unref();
601
+ }