jskelet 0.6.3 → 0.6.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (153) hide show
  1. package/AGENTS.md +136 -136
  2. package/CHANGELOG.md +628 -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 +667 -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 +348 -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 +675 -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 +351 -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 +369 -356
  134. package/src/server/port-guard.js +255 -255
  135. package/src/server/prewarm.js +1082 -1082
  136. package/src/server/redis.js +588 -588
  137. package/src/server/render.js +910 -910
  138. package/src/server/router.js +157 -157
  139. package/src/server/status-page.js +265 -265
  140. package/src/server/upstream-limiter.js +376 -376
  141. package/src/server/upstream-tracking.js +166 -166
  142. package/src/shared/cookie-domain.js +66 -66
  143. package/src/start.mjs +22 -22
  144. package/src/templates/layout.ejs +30 -30
  145. package/src/templates/layout.jsk +30 -30
  146. package/src/version.mjs +31 -31
  147. package/src/views/components/loader.js +101 -101
  148. package/src/views/helpers/html.js +102 -102
  149. package/src/views/helpers/tags.js +375 -375
  150. package/types/config/defaults.d.ts +6 -0
  151. package/types/config/index.d.ts +6 -0
  152. package/types/server/cache-control.d.ts +28 -0
  153. package/types/server/og-image.d.ts +5 -0
@@ -1,1082 +1,1082 @@
1
- /**
2
- * Sunucu açılışında veya ziyaret sırasında sayfaları önden render edip HTML
3
- * cache'ini doldurur.
4
- *
5
- * İki karşılıklı dışlayan mod:
6
- *
7
- * 1. **Klasik** — `hooks.prewarmPaths()` listesi, açılış/`intervalSeconds`
8
- * turları, `priority` / `rotate` / `max`.
9
- * 2. **onVisit** — herkese açık bir sayfa servis edilince HTML'deki
10
- * aynı-origin linkler kuyruğa alınır; tıklanabilir komşular ısınır.
11
- *
12
- * Next'teki build-time prerender'ın karşılığı, ama çıktı diske yazılmaz:
13
- * HTML cache süreç belleğinde yaşadığı için ısıtma da süreç ayağa kalkınca
14
- * (veya trafik geldikçe) yapılır.
15
- *
16
- * Isıtma gerçek HTTP istekleriyle yapılır: cache anahtarı, sıkıştırma ve
17
- * middleware zinciri normal trafikle bire bir aynı olsun.
18
- */
19
- import process from "node:process";
20
- import { ON_VISIT_QUEUE_MAX } from "../config/defaults.js";
21
- import { getConfig, hook } from "../config/index.js";
22
- import { getRequestContext } from "../http/request-context.js";
23
- import { publicHost } from "./cache-vary.js";
24
- import {
25
- isHtmlCacheFresh,
26
- isHtmlCacheKeyFresh,
27
- startEarlyExpirySweep,
28
- takeInvalidatedPaths,
29
- takeInvalidatedTargets,
30
- } from "./html-cache.js";
31
- import { getDataCacheStats } from "./data-cache.js";
32
- import { isTransientStatus } from "./upstream-tracking.js";
33
- import { upstreamCooldownMs } from "./upstream-limiter.js";
34
-
35
- /**
36
- * `cache().prewarm.origins` yoksa loopback. Port'suz origin'lere dinleme
37
- * portu eklenir — `http://tr.localhost` → `http://tr.localhost:3000`.
38
- *
39
- * @param {number} port
40
- * @returns {string[]}
41
- */
42
- function resolvePrewarmOrigins(port) {
43
- const configured = getConfig().prewarm?.origins;
44
- const list = Array.isArray(configured)
45
- ? configured.filter((value) => typeof value === "string" && value)
46
- : [];
47
-
48
- if (!list.length) return [`http://127.0.0.1:${port}`];
49
-
50
- return list.map((origin) => {
51
- try {
52
- const url = new URL(origin);
53
- if (!url.port) url.port = String(port);
54
- return url.origin;
55
- } catch {
56
- return origin;
57
- }
58
- });
59
- }
60
-
61
- /**
62
- * `vary.host` açıkken ısıtma isteğinin cache anahtarı ziyaretçinin public
63
- * host'u olsun. Fetch'in kendi `Host` başlığı loopback'tir ve değiştirilemez;
64
- * `x-forwarded-host` ise `publicHost` tarafından önce okunur. İsteği public
65
- * origin'e yollamak ALB üzerinden döner ve `h=127.0.0.1` diye ikinci girdi açar.
66
- *
67
- * @param {{ get?: (name: string) => string | undefined,
68
- * headers?: Record<string, unknown> } | undefined} req
69
- * @returns {string}
70
- */
71
- function forwardedHostFrom(req) {
72
- if (!req) return "";
73
- try {
74
- if (!getConfig().cacheVary?.host) return "";
75
- } catch {
76
- return "";
77
- }
78
- return publicHost(req);
79
- }
80
-
81
- /**
82
- * Tazelik kontrolü, ısıtma isteğinin göreceği host ile aynı öneki arasın.
83
- *
84
- * @param {string} host
85
- * @returns {{ headers: Record<string, string>, get: (name: string) => string | undefined } | undefined}
86
- */
87
- function requestForHost(host) {
88
- if (!host) return undefined;
89
- return {
90
- headers: { host, "x-forwarded-host": host },
91
- get(name) {
92
- return this.headers[name];
93
- },
94
- };
95
- }
96
-
97
- /** Klasik turu yöneten env'ler; `onVisit` ile birlikte yasak. */
98
- const CLASSIC_PREWARM_ENV = [
99
- "PREWARM_MAX",
100
- "PREWARM_INTERVAL_SECONDS",
101
- "PREWARM_DELAY_MS",
102
- "PREWARM_RETRY_DELAY_MS",
103
- ];
104
-
105
- /**
106
- * onVisit turu sürerken concurrency/rps. Klasik moda dokunmaz (`null`).
107
- * @type {{ concurrency: number | null, rps: number | null }}
108
- */
109
- const visitWarmSettings = { concurrency: null, rps: null };
110
-
111
- /**
112
- * Süre dolumu ısıtması klasik turun `rps: 0` (sınırsız) ayarını kullanmaz.
113
- * Bir TTL uçurumu tek seferde yüzlerce render başlatmasın.
114
- */
115
- const EXPIRY_WARM_CONCURRENCY = 1;
116
- const EXPIRY_WARM_RPS = 2;
117
-
118
- /** @type {{ concurrency: number | null, rps: number | null }} */
119
- const expiryWarmSettings = { concurrency: null, rps: null };
120
-
121
- /**
122
- * Isıtmanın canlı durumu. Dev araçları bunu okuyup ilerlemeyi gösterir;
123
- * üretimde kimse okumazsa da maliyeti bir nesnedir.
124
- */
125
- export const prewarmProgress = {
126
- active: false,
127
- done: 0,
128
- total: 0,
129
- ok: 0,
130
- failed: 0,
131
- /** @type {number | null} */
132
- startedAt: null,
133
- /** @type {number | null} */
134
- finishedAt: null,
135
- /**
136
- * Denenen her yolun sonucu; dev panelindeki Prewarming sekmesi bunu listeler.
137
- * @type {{ path: string, status: number, ms: number, bytes: number,
138
- * cache: string | null, error: string | null }[]}
139
- */
140
- entries: [],
141
- };
142
-
143
- /**
144
- * Isıtma turu sırasında bastırılan uyarılar: mesaj → kaç kez görüldü.
145
- *
146
- * Yüzlerce yolu tarayan bir tur, upstream bir an için tıksırdığında yüzlerce
147
- * satır loga döküyor ve asıl bilgi (kaç sayfa ısındı) kayboluyor. Tur boyunca
148
- * mesajlar burada toplanır, tur bitince tek satırda özetlenir. Yol adı
149
- * anahtara girmez: gruplanabilmesi için mesajın kendisi yeterli, tek bir yolun
150
- * ayrıntısı zaten `entries` üzerinden dev panelinde görünüyor.
151
- * @type {Map<string, number>}
152
- */
153
- const suppressed = new Map();
154
-
155
- /**
156
- * İstek ısıtma turunun kendi isteği mi? Yalnızca tur çalışırken ve istek
157
- * ısıtmanın user-agent'ıyla geldiğinde doğru; gerçek trafiğin hataları her
158
- * zaman loglanmaya devam eder.
159
- *
160
- * @param {{ get?: (name: string) => string | undefined } | null | undefined} req
161
- * @returns {boolean}
162
- */
163
- export function isPrewarmRequest(req) {
164
- if (!prewarmProgress.active || !req) return false;
165
- const ua = req.get?.("user-agent");
166
- return Boolean(ua) && ua === getConfig().brand.prewarmUserAgent;
167
- }
168
-
169
- /**
170
- * Şu an işlenen istek ısıtma turuna mı ait? `req` elde olmayan derin
171
- * katmanlar (render, cache) için: yanıt nesnesi istek bağlamında taşınıyor.
172
- *
173
- * @returns {boolean}
174
- */
175
- function inPrewarmRequest() {
176
- if (!prewarmProgress.active) return false;
177
- return isPrewarmRequest(getRequestContext()?.res?.req);
178
- }
179
-
180
- /**
181
- * Isıtma turuna ait bir uyarıyı loglamak yerine sayar.
182
- *
183
- * @param {string} message Gruplama anahtarı; yol adı içermemeli.
184
- * @returns {boolean} `true` ise sayıldı, çağıran taraf loglamamalı.
185
- */
186
- export function suppressForPrewarm(message) {
187
- if (!inPrewarmRequest()) return false;
188
- suppressed.set(message, (suppressed.get(message) ?? 0) + 1);
189
- return true;
190
- }
191
-
192
- /**
193
- * Bastırılan bir istek hatasını sayaca ekler. Yığın izi saklanmaz: özet
194
- * satırının amacı "neyin bozulduğunu" göstermek, hatayı ayıklamak değil.
195
- *
196
- * @param {number} status
197
- * @param {unknown} error
198
- * @returns {void}
199
- */
200
- export function notePrewarmError(status, error) {
201
- const message = error instanceof Error ? error.message : String(error);
202
- const key = `${status} ${message.split("\n")[0]}`;
203
- suppressed.set(key, (suppressed.get(key) ?? 0) + 1);
204
- }
205
-
206
- /** @param {unknown} value @param {number} fallback */
207
- function num(value, fallback) {
208
- const parsed = Number(value);
209
- return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
210
- }
211
-
212
- /**
213
- * Ayar sırası: ortam değişkeni → `jskelet.config.mjs` → kod varsayılanı.
214
- * Env önde, çünkü tek seferlik deneyler config'i düzenlemeden yapılabilsin.
215
- *
216
- * @param {string} envKey
217
- * @param {string} configKey
218
- * @param {number} fallback
219
- * @returns {number}
220
- */
221
- function setting(envKey, configKey, fallback) {
222
- if (
223
- expiryWarmSettings.concurrency != null &&
224
- configKey === "concurrency"
225
- ) {
226
- return expiryWarmSettings.concurrency;
227
- }
228
- if (expiryWarmSettings.rps != null && configKey === "rps") {
229
- return expiryWarmSettings.rps;
230
- }
231
- if (
232
- visitWarmSettings.concurrency != null &&
233
- configKey === "concurrency"
234
- ) {
235
- return visitWarmSettings.concurrency;
236
- }
237
- if (visitWarmSettings.rps != null && configKey === "rps") {
238
- return visitWarmSettings.rps;
239
- }
240
- return num(process.env[envKey], num(getConfig().prewarm?.[configKey], fallback));
241
- }
242
-
243
- /**
244
- * @returns {Promise<string[]>}
245
- */
246
- async function collectPaths() {
247
- const { prewarmSkip } = getConfig();
248
- const paths = await hook("prewarmPaths", []);
249
-
250
- if (!Array.isArray(paths)) {
251
- console.warn("[prewarm] hooks.prewarmPaths() must return an array, ignoring it");
252
- return [];
253
- }
254
-
255
- // Tekilleştirme sırayı korur: liste `PREWARM_MAX` ile budandığı için
256
- // uygulamanın verdiği öncelik sırası anlamlıdır.
257
- return [...new Set(paths)].filter(
258
- (candidate) =>
259
- typeof candidate === "string" &&
260
- candidate.startsWith("/") &&
261
- !prewarmSkip.some((prefix) => candidate.startsWith(prefix)),
262
- );
263
- }
264
-
265
- /**
266
- * `cache().prewarm.priority` desenlerine göre sıralar. Eşleşmeyen yollar
267
- * listenin sonuna, kendi aralarındaki sırayı koruyarak gider — uygulamanın
268
- * verdiği sıra hâlâ anlamlı olsun.
269
- *
270
- * @param {string[]} paths
271
- * @returns {{ head: string[], tail: string[] }}
272
- * `head` öncelikli yollar (her turda ısıtılır), `tail` geri kalan kuyruk
273
- * (turlar arasında dolaşılır).
274
- */
275
- function byPriority(paths) {
276
- const rules = getConfig().prewarmPriority;
277
- if (!rules.length) return { head: [], tail: paths };
278
-
279
- /** @type {string[][]} */
280
- const buckets = rules.map(() => []);
281
- /** @type {string[]} */
282
- const tail = [];
283
-
284
- for (const candidate of paths) {
285
- const rank = rules.findIndex((rule) => rule.test(candidate));
286
- if (rank === -1) tail.push(candidate);
287
- else buckets[rank].push(candidate);
288
- }
289
-
290
- return { head: buckets.flat(), tail };
291
- }
292
-
293
- /**
294
- * Kuyruğun kaldığı yer. Periyodik turlar listeyi baştan ısıtıp aynı ilk
295
- * `max` yolu tekrar tekrar tazelemesin: her tur bir sonraki dilimi alır ve
296
- * yeterli tur sonunda liste baştan sona ısınır.
297
- */
298
- let queueCursor = 0;
299
-
300
- /**
301
- * Bir turda ısıtılacak dilimi seçer: önce `priority` eşleşenler, sonra
302
- * kuyruğun sırası gelen parçası. Dışa açık olması bilinçli — sıralama ve
303
- * rotasyon, tur çalışmadan doğrulanabilen tek davranış.
304
- *
305
- * @param {string[]} all
306
- * @param {number} limit
307
- * @param {boolean} rotate
308
- * @returns {string[]}
309
- */
310
- export function selectPrewarmPaths(all, limit, rotate = true) {
311
- if (all.length <= limit) return all;
312
-
313
- const { head, tail } = byPriority(all);
314
- const selected = head.slice(0, limit);
315
- const room = limit - selected.length;
316
- if (room <= 0 || !tail.length) return selected;
317
-
318
- if (!rotate) return [...selected, ...tail.slice(0, room)];
319
-
320
- // Dilim listenin sonunu aşarsa başa sarar: kuyruk halkasal dolaşılır.
321
- const start = queueCursor % tail.length;
322
- const slice = tail.slice(start, start + room);
323
- if (slice.length < room) slice.push(...tail.slice(0, room - slice.length));
324
- queueCursor = (start + room) % tail.length;
325
-
326
- return [...selected, ...slice];
327
- }
328
-
329
- /** @param {number} ms */
330
- function sleep(ms) {
331
- return new Promise((resolve) => {
332
- setTimeout(resolve, ms).unref?.();
333
- });
334
- }
335
-
336
- /**
337
- * Saniyedeki istek sayısını sınırlar. Fren `concurrency`'den bağımsız
338
- * olmalı: paralellik gecikmeyi kapatmak için var, kotayı koruyan şey toplam
339
- * hız. İşçiler aynı sayacı paylaştığı için sıra kimde olursa olsun tur
340
- * verilen hızın üstüne çıkmaz.
341
- *
342
- * @param {number} rps 0 → sınırsız
343
- * @returns {() => Promise<void>}
344
- */
345
- function createPacer(rps) {
346
- if (!rps) return async () => {};
347
-
348
- const gap = 1000 / rps;
349
- let nextSlot = 0;
350
-
351
- return async () => {
352
- const now = Date.now();
353
- const slot = Math.max(now, nextSlot);
354
- nextSlot = slot + gap;
355
- if (slot > now) await sleep(slot - now);
356
- };
357
- }
358
-
359
- /**
360
- * Gate açıkken token taşımayan her isteğe 404 döner. Isıtma kendi sunucusuna
361
- * istek attığı için token'ı çerez olarak taşımalı; yoksa tüm sayfalar 404
362
- * alır ve önbellek hiç dolmaz. Gate kapalıyken çerez gönderilmez.
363
- *
364
- * @returns {Record<string, string>}
365
- */
366
- function devGateHeader() {
367
- try {
368
- if (!getConfig().devGate) return {};
369
- } catch {
370
- return {};
371
- }
372
-
373
- const token = process.env.DEV_TOKEN;
374
- if (!token) return {};
375
-
376
- const cookie = getConfig().brand.devTokenCookie;
377
- return { cookie: `${cookie}=${encodeURIComponent(token)}` };
378
- }
379
-
380
- /**
381
- * @param {string} origin
382
- * @param {string[]} paths
383
- * @param {number} concurrency
384
- * @param {(ok: number, failed: number) => void} [report]
385
- * Tur ilerlemesini `prewarmProgress`'e yazar. Tekrar turunda sayaçların
386
- * anlamı değiştiği için çağıran taraf kendi formülünü verir.
387
- * @param {() => Promise<void>} [pace] İstek başına beklenen hız freni.
388
- * @param {string} [forwardedHost] `vary.host` için `x-forwarded-host`.
389
- * @returns {Promise<{ ok: number, failed: number,
390
- * failures: { path: string, status: number }[] }>}
391
- * `failures` durum koduyla birlikte döner: tekrar turuna yalnızca geçici
392
- * hatalar alınıyor, kalıcı olanı yeniden denemek boşa çağrı.
393
- */
394
- async function crawl(
395
- origin,
396
- paths,
397
- concurrency,
398
- report = undefined,
399
- pace = undefined,
400
- forwardedHost = "",
401
- ) {
402
- const { brand } = getConfig();
403
- const cacheHeader = brand.cacheHeader.toLowerCase();
404
-
405
- let index = 0;
406
- let ok = 0;
407
- let failed = 0;
408
- /** @type {{ path: string, status: number }[]} */
409
- const failures = [];
410
-
411
- async function worker() {
412
- while (index < paths.length) {
413
- const target = paths[index];
414
- index += 1;
415
-
416
- if (pace) await pace();
417
-
418
- const startedAt = Date.now();
419
-
420
- try {
421
- const response = await fetch(`${origin}${target}`, {
422
- headers: {
423
- // Sıkıştırılmış gövde de cache'lensin.
424
- "accept-encoding": "br, gzip",
425
- "user-agent": brand.prewarmUserAgent,
426
- ...(forwardedHost ? { "x-forwarded-host": forwardedHost } : {}),
427
- ...devGateHeader(),
428
- },
429
- });
430
- // Gövde okunmadan bağlantı açık kalır.
431
- const body = await response.arrayBuffer();
432
- if (response.ok) ok += 1;
433
- else {
434
- failed += 1;
435
- failures.push({ path: target, status: response.status });
436
- }
437
-
438
- prewarmProgress.entries.push({
439
- path: target,
440
- status: response.status,
441
- ms: Date.now() - startedAt,
442
- bytes: body.byteLength,
443
- cache: response.headers.get(cacheHeader),
444
- error: response.ok ? null : `HTTP ${response.status}`,
445
- });
446
- } catch (error) {
447
- failed += 1;
448
- // Yanıt hiç gelmedi: ağ hatası her zaman geçici, tekrar denenir.
449
- failures.push({ path: target, status: 0 });
450
- prewarmProgress.entries.push({
451
- path: target,
452
- status: 0,
453
- ms: Date.now() - startedAt,
454
- bytes: 0,
455
- cache: null,
456
- error: error instanceof Error ? error.message : String(error),
457
- });
458
- }
459
-
460
- if (report) {
461
- report(ok, failed);
462
- } else {
463
- prewarmProgress.done = ok + failed;
464
- prewarmProgress.ok = ok;
465
- prewarmProgress.failed = failed;
466
- }
467
- }
468
- }
469
-
470
- await Promise.all(
471
- Array.from({ length: Math.min(concurrency, paths.length) }, worker),
472
- );
473
-
474
- return { ok, failed, failures };
475
- }
476
-
477
- /**
478
- * Tekrar turuna girecek yollar. Yalnızca geçici hatalar: `400`/`403`/`404` gibi
479
- * deterministik cevaplar tekrar denemekle düzelmez ve o çağrılar kotadan
480
- * karşılıksız yiyor. Sınıflandırma render tarafıyla aynı listeden
481
- * (`isTransientStatus`), yoksa iki yer birbirinden kayar.
482
- *
483
- * @param {{ path: string, status: number }[]} failures
484
- * @returns {string[]}
485
- */
486
- function retryablePaths(failures) {
487
- return failures
488
- .filter((failure) => isTransientStatus(failure.status))
489
- .map((failure) => failure.path);
490
- }
491
-
492
- /**
493
- * Tekrar turundan önce beklenecek süre.
494
- *
495
- * Sabit bekleme yanlış soruyu cevaplıyordu: doğru süreyi upstream biliyor.
496
- * Hız freni açıkken `Retry-After` ya da devre kesicinin soğuma süresi zaten
497
- * elimizde; 10 saniye kapalı kalacak bir kesiciden 2 saniye sonra tekrar
498
- * denemek, aynı 429'u peşin peşin almak demek.
499
- *
500
- * @returns {number} ms
501
- */
502
- function retryDelayMs() {
503
- const configured = setting("PREWARM_RETRY_DELAY_MS", "retryDelayMs", 2000);
504
- const cooldown = upstreamCooldownMs();
505
-
506
- // Fren kapalıysa `cooldown` 0 olur ve davranış eskisi gibi kalır. Üst sınır
507
- // turun sonsuza kadar açık kalmasını engelliyor.
508
- return Math.min(Math.max(configured, cooldown), 60_000);
509
- }
510
-
511
- /**
512
- * Turun veri önbelleği üzerinden upstream'e ne kadar dokunduğunu özetler.
513
- *
514
- * @param {ReturnType<typeof getDataCacheStats>} before Tur başındaki sayaçlar.
515
- * @returns {string} Okunacak bir şey yoksa boş dize.
516
- */
517
- function upstreamUsage(before) {
518
- const after = getDataCacheStats();
519
- const reads = after.reads - before.reads;
520
- if (reads <= 0) return "";
521
-
522
- const produced = after.produced - before.produced;
523
- const coalesced = after.coalesced - before.coalesced;
524
- const shared = after.shared - before.shared;
525
- const served = reads - produced;
526
- const ratio = Math.round((served / reads) * 100);
527
-
528
- return (
529
- `${produced} upstream call${produced === 1 ? "" : "s"} for ${reads} data read${reads === 1 ? "" : "s"} ` +
530
- `(${ratio}% from the data cache` +
531
- `${coalesced ? `, ${coalesced} coalesced` : ""}` +
532
- `${shared ? `, ${shared} from the shared tier` : ""})`
533
- );
534
- }
535
-
536
- /**
537
- * @param {{ origin: string, quiet?: boolean, paths?: string[],
538
- * forwardedHost?: string }} options
539
- * `paths` verilirse hook çağrılmaz, yalnızca o yollar ısıtılır (dev
540
- * panelindeki "tekrar dene" bunu kullanır). `forwardedHost` loopback
541
- * isteğine public host'u taşır; cache anahtarı `h=127.0.0.1` olmasın.
542
- * @returns {Promise<{ ok: number, failed: number, total: number, elapsed: number }>}
543
- */
544
- export async function prewarm({ origin, quiet = false, paths: only, forwardedHost = "" }) {
545
- const started = Date.now();
546
- const limit = setting("PREWARM_MAX", "max", 400);
547
- const isDev = process.env.NODE_ENV === "development";
548
-
549
- // Dev'de tek işçi: tarama, o an tarayıcıda açtığın sayfanın render'ıyla CPU
550
- // için yarışmasın.
551
- const concurrency = setting(
552
- "PREWARM_CONCURRENCY",
553
- "concurrency",
554
- isDev ? 1 : 4,
555
- );
556
-
557
- // Render tek bir olay döngüsünde çalışıyor: aralıksız bir tur, geliştirme
558
- // sırasında sayfa isteklerini ve dev panelinin kanalını arkasında bekletiyor.
559
- // Dev'de varsayılan bir hız freni bu yüzden var; üretimde ısıtma bir kez
560
- // olup bittiği için fren yalnızca istenirse (`prewarm.rps`) devreye girer.
561
- // onVisit turlarında `visitWarmSettings.rps` `setting()` üzerinden iner.
562
- const rps = setting("PREWARM_RPS", "rps", isDev ? 4 : 0);
563
- const pace = createPacer(rps);
564
-
565
- const all = only?.length ? only : await collectPaths();
566
- // Elle verilen liste budanmaz ve sıralanmaz: çağıran tam olarak neyi
567
- // istediğini biliyor (dev panelindeki "tekrar dene" bunu kullanır).
568
- const selected = only?.length
569
- ? all
570
- : selectPrewarmPaths(all, limit, getConfig().prewarm?.rotate !== false);
571
-
572
- // Invalidate edilmiş sayfalar kuyruğun önüne geçer: "içerik güncellendi"
573
- // bilgisi geldiğinde sayfa, ziyaretçi gelmesini beklemeden tazelenir. Bu
574
- // yollar `max` bütçesinin dışında tutulur — sayıları zaten gerçekleşen
575
- // invalidation kadar ve rotasyonun sırasını bozmaları istenmez.
576
- const pending = only?.length ? [] : takeInvalidatedPaths();
577
- const paths = pending.length
578
- ? [...new Set([...pending, ...selected])]
579
- : selected;
580
-
581
- Object.assign(prewarmProgress, {
582
- active: true,
583
- done: 0,
584
- total: paths.length,
585
- ok: 0,
586
- failed: 0,
587
- startedAt: started,
588
- finishedAt: null,
589
- entries: [],
590
- });
591
- suppressed.clear();
592
-
593
- // Kotanın gerçekten ne kadar harcandığı ancak veri önbelleğinden görülür:
594
- // 400 sayfalık bir tur, ortak bir uç için tek çağrı da yapabilir dört yüz de.
595
- const dataBefore = getDataCacheStats();
596
-
597
- let ok = 0;
598
- let failed = 0;
599
- let recovered = 0;
600
- let skippedRetry = 0;
601
- try {
602
- /** @type {{ path: string, status: number }[]} */
603
- let failures;
604
- ({ ok, failed, failures } = await crawl(
605
- origin,
606
- paths,
607
- concurrency,
608
- undefined,
609
- pace,
610
- forwardedHost,
611
- ));
612
-
613
- // Hatalar çoğunlukla upstream rate limit'i (429): ilk tur yüzlerce sayfayı
614
- // aynı anda çekerken API'yi zorluyor. Tek seri tekrar turu bu sayfaların
615
- // önbelleğe girmesini sağlıyor; aksi hâlde ziyaretçi soğuk render'ı öder.
616
- const retryPaths = retryablePaths(failures);
617
- skippedRetry = failures.length - retryPaths.length;
618
-
619
- if (retryPaths.length) {
620
- const firstOk = ok;
621
- const firstFailed = failed;
622
-
623
- await sleep(retryDelayMs());
624
-
625
- const retry = await crawl(
626
- origin,
627
- retryPaths,
628
- 1,
629
- (retriedOk) => {
630
- // Tekrar turunda her başarı bir hatayı başarıya çevirir.
631
- prewarmProgress.ok = firstOk + retriedOk;
632
- prewarmProgress.failed = firstFailed - retriedOk;
633
- },
634
- pace,
635
- forwardedHost,
636
- );
637
- recovered = retry.ok;
638
- ok += retry.ok;
639
- failed -= retry.ok;
640
- }
641
- } finally {
642
- prewarmProgress.active = false;
643
- prewarmProgress.finishedAt = Date.now();
644
- }
645
- const elapsed = Date.now() - started;
646
-
647
- if (!quiet && paths.length) {
648
- const skipped = all.length - selected.length;
649
- // Rotasyon açıkken sınırın dışında kalan yollar kaybolmuyor, bir sonraki
650
- // tura kalıyor; log bunu ayırt etmeli, yoksa "400 yol atlandı" satırı
651
- // hatalı bir kurulum sanılıyor.
652
- const rotate = !only?.length && getConfig().prewarm?.rotate !== false;
653
- console.log(
654
- `[prewarm] warmed ${ok}/${paths.length} pages` +
655
- `${pending.length ? `, ${pending.length} invalidated` : ""}` +
656
- `${failed ? `, ${failed} failed` : ""}` +
657
- `${recovered ? `, ${recovered} recovered on the retry pass` : ""}` +
658
- `${skippedRetry ? `, ${skippedRetry} not retried (permanent)` : ""}` +
659
- `${skipped > 0 ? `, ${skipped} ${rotate ? "deferred to the next pass" : "over the limit"}` : ""}` +
660
- ` (${(elapsed / 1000).toFixed(1)}s)`,
661
- );
662
-
663
- // Turun upstream'e ne kadar dokunduğu. Asıl karar bu satıra bakılarak
664
- // veriliyor: oran düşükse çözüm hız freni değil, `withDataCache` TTL'ini
665
- // tur aralığından uzun tutmak — fren çağrıları yavaşlatır, sayısını
666
- // azaltmaz.
667
- const upstreamCalls = upstreamUsage(dataBefore);
668
- if (upstreamCalls) console.log(`[prewarm] ${upstreamCalls}`);
669
-
670
- // Tur boyunca bastırılan hatalar: en sık görülenler önce, liste uzarsa
671
- // kalanı tek satırda toplanır. Amaç, logu şişirmeden "ne bozuldu"yu
672
- // görünür tutmak.
673
- if (suppressed.size) {
674
- const ranked = [...suppressed.entries()].sort((a, b) => b[1] - a[1]);
675
- const shown = ranked.slice(0, 5);
676
- const rest = ranked.slice(shown.length).reduce((sum, [, n]) => sum + n, 0);
677
- const total = ranked.reduce((sum, [, n]) => sum + n, 0);
678
-
679
- console.warn(
680
- `[prewarm] ${total} problem${total === 1 ? " was" : "s were"} not logged individually:\n` +
681
- shown.map(([message, n]) => ` ${n}× ${message}`).join("\n") +
682
- (rest ? `\n … ${rest} more in ${ranked.length - shown.length} other kinds` : ""),
683
- );
684
- }
685
- }
686
-
687
- return { ok, failed, total: paths.length, elapsed };
688
- }
689
-
690
- /**
691
- * Speculation Rules `href_matches` benzeri dışlama: tam yol veya `/*` öneki.
692
- *
693
- * @param {string} pathname
694
- * @param {string[]} patterns
695
- * @returns {boolean}
696
- */
697
- function matchesHrefExclude(pathname, patterns) {
698
- for (const pattern of patterns) {
699
- if (typeof pattern !== "string" || !pattern) continue;
700
- if (pattern.endsWith("/*")) {
701
- const base = pattern.slice(0, -2);
702
- if (pathname === base || pathname.startsWith(`${base}/`)) return true;
703
- continue;
704
- }
705
- if (pathname === pattern) return true;
706
- }
707
- return false;
708
- }
709
-
710
- /**
711
- * HTML içindeki aynı-origin `<a href>` yollarını DOM sırasıyla (üstten alta)
712
- * toplar. Speculation Rules ile aynı muafiyetler: `nofollow`, `_blank`,
713
- * `data-no-prefetch`, `prewarmSkip`, `navigation.exclude`.
714
- *
715
- * @param {string} html
716
- * @param {{ limit?: number, basePath?: string }} [options]
717
- * @returns {string[]}
718
- */
719
- export function extractSameOriginLinks(html, options = {}) {
720
- if (typeof html !== "string" || !html) return [];
721
-
722
- const limit = Math.max(1, Math.floor(Number(options.limit) || 20));
723
- const { prewarmSkip, navigation } = getConfig();
724
- const exclude = navigation?.exclude ?? [];
725
-
726
- /** @type {string[]} */
727
- const links = [];
728
- const seen = new Set();
729
- const tagRe = /<a\b([^>]*)>/gi;
730
- let match;
731
-
732
- while ((match = tagRe.exec(html)) !== null && links.length < limit) {
733
- const attrs = match[1];
734
- if (/\btarget\s*=\s*(?:"_blank"|'_blank'|_blank)(?=[\s>]|$)/i.test(attrs)) {
735
- continue;
736
- }
737
- if (/\bdata-no-prefetch\b/i.test(attrs)) continue;
738
-
739
- const relMatch = attrs.match(
740
- /\brel\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+))/i,
741
- );
742
- const rel = relMatch?.[1] ?? relMatch?.[2] ?? relMatch?.[3] ?? "";
743
- if (/\bnofollow\b/i.test(rel)) continue;
744
-
745
- const hrefMatch = attrs.match(
746
- /\bhref\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+))/i,
747
- );
748
- const href = hrefMatch?.[1] ?? hrefMatch?.[2] ?? hrefMatch?.[3];
749
- if (!href) continue;
750
-
751
- const path = normalizeWarmPath(href, options.basePath);
752
- if (!path) continue;
753
- if (prewarmSkip.some((prefix) => path.startsWith(prefix))) continue;
754
- if (matchesHrefExclude(path, exclude)) continue;
755
- if (seen.has(path)) continue;
756
-
757
- seen.add(path);
758
- links.push(path);
759
- }
760
-
761
- return links;
762
- }
763
-
764
- /**
765
- * @param {string} href
766
- * @param {string} [basePath] Ziyaret edilen sayfa; göreli href çözümü için.
767
- * @returns {string | null}
768
- */
769
- function normalizeWarmPath(href, basePath = "/") {
770
- const trimmed = href.trim();
771
- if (!trimmed || trimmed.startsWith("#")) return null;
772
- if (/^(mailto|tel|javascript|data):/i.test(trimmed)) return null;
773
-
774
- try {
775
- const basePathname = basePath.startsWith("/") ? basePath : `/${basePath}`;
776
- const url = new URL(trimmed, `https://warm.invalid${basePathname}`);
777
- // Dış origin veya `https://…` mutlak linkler elenir; yalnızca site-içi
778
- // path / göreli href ısınır.
779
- if (url.origin !== "https://warm.invalid") return null;
780
- return url.pathname || "/";
781
- } catch {
782
- return null;
783
- }
784
- }
785
-
786
- /** @type {string | null} */
787
- let visitOrigin = null;
788
- /**
789
- * onVisit turunun `x-forwarded-host` değeri. Boşsa vary kapalıdır ve
790
- * loopback anahtarı doğrudur.
791
- * @type {string}
792
- */
793
- let visitForwardedHost = "";
794
- /** @type {string[]} */
795
- const visitPending = [];
796
- /** @type {Set<string>} */
797
- const visitQueued = new Set();
798
- let visitDraining = false;
799
-
800
- /**
801
- * Ziyaret ısıtması açık mı? `PREWARM=0` her iki modu da keser.
802
- *
803
- * @returns {boolean}
804
- */
805
- export function isOnVisitPrewarm() {
806
- if (process.env.PREWARM === "0") return false;
807
- try {
808
- return getConfig().prewarm?.onVisit?.enabled === true;
809
- } catch {
810
- return false;
811
- }
812
- }
813
-
814
- /**
815
- * Yanıt gövdesindeki linkleri soğuksa kuyruğa alır. İstek yolunu bloklamamak
816
- * için `route()` bunu `queueMicrotask` ile çağırır.
817
- *
818
- * @param {string} html
819
- * @param {{ path: string, req?: { get?: (name: string) => string | undefined,
820
- * headers?: Record<string, unknown> } }} context
821
- * @returns {void}
822
- */
823
- export function noteVisitWarm(html, context) {
824
- if (!isOnVisitPrewarm() || !visitOrigin) return;
825
- if (!context?.path || typeof html !== "string") return;
826
-
827
- const ua =
828
- context.req?.get?.("user-agent") ??
829
- /** @type {string | undefined} */ (context.req?.headers?.["user-agent"]);
830
- if (ua && ua === getConfig().brand.prewarmUserAgent) return;
831
-
832
- // Public origin'e çıkmak yerine loopback'te kal, host'u başlıkla taşı.
833
- visitForwardedHost = forwardedHostFrom(context.req);
834
- const freshReq = requestForHost(visitForwardedHost);
835
-
836
- const perPage = Number(getConfig().prewarm?.onVisit?.perPage) || 20;
837
- const links = extractSameOriginLinks(html, {
838
- limit: perPage,
839
- basePath: context.path,
840
- });
841
-
842
- for (const path of links) {
843
- if (path === context.path) continue;
844
- if (isHtmlCacheFresh(path, freshReq)) continue;
845
- if (!enqueueWarmPath(path)) break;
846
- }
847
-
848
- enqueueInvalidatedForVisit();
849
- if (visitPending.length) void drainVisitWarm();
850
- }
851
-
852
- /**
853
- * Kuyruk tavanı dolunca yeni yol alınmaz. Birikmiş yüzlerce link, rps
854
- * freni olsa bile task'ı uzun süre meşgul eder.
855
- *
856
- * @param {string} path
857
- * @returns {boolean} Kuyruğa girdi mi.
858
- */
859
- function enqueueWarmPath(path) {
860
- if (visitPending.length >= ON_VISIT_QUEUE_MAX) return false;
861
- if (visitQueued.has(path)) return false;
862
- visitQueued.add(path);
863
- visitPending.push(path);
864
- return true;
865
- }
866
-
867
- /**
868
- * Invalidate edilmiş yolları da kuyruğa alır (klasik turdaki
869
- * `takeInvalidatedPaths` karşılığı).
870
- *
871
- * @returns {void}
872
- */
873
- function enqueueInvalidatedForVisit() {
874
- // Başka locale'in anahtarını bu ziyaretçinin host'uyla ısıtma; süre
875
- // dolumu onları kendi `x-forwarded-host` değeriyle alır.
876
- for (const target of takeInvalidatedTargets(visitForwardedHost || undefined)) {
877
- if (isHtmlCacheFresh(target.path, requestForHost(target.host || visitForwardedHost))) {
878
- continue;
879
- }
880
- if (!enqueueWarmPath(target.path)) break;
881
- }
882
- }
883
-
884
- async function drainVisitWarm() {
885
- if (visitDraining || !visitOrigin) return;
886
- visitDraining = true;
887
-
888
- const onVisit = getConfig().prewarm?.onVisit ?? {};
889
-
890
- try {
891
- while (visitPending.length) {
892
- enqueueInvalidatedForVisit();
893
-
894
- const batch = visitPending.splice(0, 32);
895
- for (const path of batch) visitQueued.delete(path);
896
-
897
- const cold = batch.filter(
898
- (path) => !isHtmlCacheFresh(path, requestForHost(visitForwardedHost)),
899
- );
900
- if (!cold.length) continue;
901
-
902
- // Klasik `prewarm()` turunu yeniden kullan: retry, progress, UA aynı.
903
- // onVisit.concurrency / rps config tavanına çekilmiş hâlde iner.
904
- visitWarmSettings.concurrency = onVisit.concurrency;
905
- visitWarmSettings.rps = onVisit.rps;
906
-
907
- try {
908
- await prewarm({
909
- origin: visitOrigin,
910
- paths: cold,
911
- quiet: true,
912
- forwardedHost: visitForwardedHost,
913
- });
914
- } catch (error) {
915
- console.error("[prewarm] onVisit warm failed", error);
916
- } finally {
917
- visitWarmSettings.concurrency = null;
918
- visitWarmSettings.rps = null;
919
- }
920
- }
921
- } finally {
922
- visitDraining = false;
923
- if (visitPending.length) void drainVisitWarm();
924
- }
925
- }
926
-
927
- /**
928
- * Açılışta ısıtmayı tetikler. `listen` geri çağrısından çağrılır.
929
- * `onVisit` modunda klasik zamanlayıcı yok; yine de erken-TTL invalidation
930
- * drain'i çalışır — soft-bayatlayan sweeper'ın kuyruğu boşalmasın.
931
- *
932
- * @param {{ port: number }} options
933
- * @returns {void}
934
- */
935
- export function startPrewarm({ port }) {
936
- const config = getConfig();
937
- if (process.env.PREWARM === "0") return;
938
-
939
- const origins = resolvePrewarmOrigins(port);
940
- const origin = origins[0];
941
-
942
- // Klasik `prewarmPaths` olmasa da TTL öncesi soft-bayatlayan girdiler
943
- // HTTP ile ısıtılsın. `PREWARM=0` yukarıda her şeyi keser.
944
- startEarlyExpirySweep();
945
- startExpiryWarmDrain(origins);
946
-
947
- if (config.prewarm?.onVisit?.enabled) {
948
- const classicEnv = CLASSIC_PREWARM_ENV.filter((key) => process.env[key]);
949
- if (classicEnv.length) {
950
- throw new Error(
951
- "[prewarm] onVisit mode cannot be used with " +
952
- `${classicEnv.join(", ")}. Those env vars belong to classic prewarm.`,
953
- );
954
- }
955
-
956
- visitOrigin = origin;
957
- console.log(
958
- `[prewarm] onVisit mode — warming links from each public page response`,
959
- );
960
- return;
961
- }
962
-
963
- if (process.env.PREWARM !== "1" && config.prewarm?.enabled === false) return;
964
- // Isıtacak yol bildirmeyen bir projede klasik tur zamanlayıcısı gerekmez;
965
- // expiry drain yine de yukarıda kuruldu.
966
- if (typeof config.hooks?.prewarmPaths !== "function") return;
967
-
968
- const isDev = process.env.NODE_ENV === "development";
969
-
970
- // Hız frenli bir tur `intervalSeconds`'tan uzun sürebilir; üst üste binen
971
- // turlar `prewarmProgress`'i bozar ve upstream'e iki kat yük bindirir.
972
- let running = false;
973
- const run = async () => {
974
- if (running) return;
975
- running = true;
976
- try {
977
- // `vary.host` açıkken her origin ayrı anahtar ısıtır.
978
- for (const next of origins) {
979
- await prewarm({ origin: next });
980
- }
981
- } catch (error) {
982
- console.error("[prewarm] failed", error);
983
- } finally {
984
- running = false;
985
- }
986
- };
987
-
988
- // Isıtma ilk isteklerle yarışmasın diye gecikmeyle başlar. Dev'de gecikme
989
- // daha uzun: dosya kaydı süreci yeniden başlattığı için zamanlayıcı da
990
- // ölür; yalnızca sunucu bir süre sakin kalınca ısınır.
991
- const delay = setting("PREWARM_DELAY_MS", "delayMs", isDev ? 3000 : 500);
992
- setTimeout(() => void run(), delay).unref();
993
-
994
- // Girdiler `revalidate` ile yaşlanır; stale-while-revalidate sayesinde
995
- // ziyaretçi beklemez. Periyodik tur, hiç ziyaret edilmeyen sayfaları da
996
- // sıcak tutmak isteyen kurulumlar için opsiyoneldir.
997
- // `rotate` ile birlikte bu ayar "damla damla ısıtma"ya dönüşür: her tur
998
- // kuyruğun bir dilimini alır, yeterli tur sonunda liste baştan sona ısınır.
999
- const interval = setting("PREWARM_INTERVAL_SECONDS", "intervalSeconds", 0);
1000
- if (interval > 0) setInterval(() => void run(), interval * 1000).unref();
1001
- }
1002
-
1003
- /** @type {string[]} */
1004
- let expiryOrigins = [];
1005
-
1006
- /** @type {boolean} */
1007
- let expiryDraining = false;
1008
-
1009
- /** @type {ReturnType<typeof setInterval> | null} */
1010
- let expiryDrainTimer = null;
1011
-
1012
- /**
1013
- * Soft-bayat / invalidate kuyruğunu periyodik boşaltır. Klasik tur ve onVisit
1014
- * aynı kuyruğu da okur; bu drain `prewarmPaths` yokken de çalışır.
1015
- *
1016
- * @param {string[]} origins
1017
- * @returns {void}
1018
- */
1019
- function startExpiryWarmDrain(origins) {
1020
- expiryOrigins = origins.length ? origins : [];
1021
- if (expiryDrainTimer) return;
1022
- expiryDrainTimer = setInterval(() => {
1023
- void drainExpiryWarm();
1024
- }, 1000);
1025
- expiryDrainTimer.unref();
1026
- }
1027
-
1028
- /**
1029
- * @returns {Promise<void>}
1030
- */
1031
- async function drainExpiryWarm() {
1032
- if (expiryDraining || !expiryOrigins.length) return;
1033
-
1034
- const targets = takeInvalidatedTargets();
1035
- if (!targets.length) return;
1036
-
1037
- expiryDraining = true;
1038
- expiryWarmSettings.concurrency = EXPIRY_WARM_CONCURRENCY;
1039
- expiryWarmSettings.rps = EXPIRY_WARM_RPS;
1040
- try {
1041
- /** @type {Map<string, string[]>} */
1042
- const byHost = new Map();
1043
- for (const target of targets) {
1044
- if (isHtmlCacheKeyFresh(target.key)) continue;
1045
- const host = target.host || "";
1046
- const list = byHost.get(host);
1047
- if (list) list.push(target.path);
1048
- else byHost.set(host, [target.path]);
1049
- }
1050
- if (!byHost.size) return;
1051
-
1052
- // Vary kapalı: anahtarda host yok, her yapılandırılmış origin ısınır.
1053
- const bare = byHost.get("") ?? [];
1054
- if (bare.length && byHost.size === 1) {
1055
- for (const origin of expiryOrigins) {
1056
- await prewarm({ origin, paths: bare, quiet: true });
1057
- }
1058
- return;
1059
- }
1060
-
1061
- // Host'lu anahtar loopback'e gider. `x-forwarded-host` cache anahtarını
1062
- // public host yapar; aksi hâlde `h=127.0.0.1|/yol` diye ikinci girdi
1063
- // açılır ve `maxEntries` slotunu yer.
1064
- const origin = expiryOrigins[0];
1065
- for (const [host, paths] of byHost) {
1066
- if (!paths.length) continue;
1067
- if (!host) {
1068
- for (const next of expiryOrigins) {
1069
- await prewarm({ origin: next, paths, quiet: true });
1070
- }
1071
- continue;
1072
- }
1073
- await prewarm({ origin, paths, quiet: true, forwardedHost: host });
1074
- }
1075
- } catch (error) {
1076
- console.error("[prewarm] expiry warm failed", error);
1077
- } finally {
1078
- expiryWarmSettings.concurrency = null;
1079
- expiryWarmSettings.rps = null;
1080
- expiryDraining = false;
1081
- }
1082
- }
1
+ /**
2
+ * Sunucu açılışında veya ziyaret sırasında sayfaları önden render edip HTML
3
+ * cache'ini doldurur.
4
+ *
5
+ * İki karşılıklı dışlayan mod:
6
+ *
7
+ * 1. **Klasik** — `hooks.prewarmPaths()` listesi, açılış/`intervalSeconds`
8
+ * turları, `priority` / `rotate` / `max`.
9
+ * 2. **onVisit** — herkese açık bir sayfa servis edilince HTML'deki
10
+ * aynı-origin linkler kuyruğa alınır; tıklanabilir komşular ısınır.
11
+ *
12
+ * Next'teki build-time prerender'ın karşılığı, ama çıktı diske yazılmaz:
13
+ * HTML cache süreç belleğinde yaşadığı için ısıtma da süreç ayağa kalkınca
14
+ * (veya trafik geldikçe) yapılır.
15
+ *
16
+ * Isıtma gerçek HTTP istekleriyle yapılır: cache anahtarı, sıkıştırma ve
17
+ * middleware zinciri normal trafikle bire bir aynı olsun.
18
+ */
19
+ import process from "node:process";
20
+ import { ON_VISIT_QUEUE_MAX } from "../config/defaults.js";
21
+ import { getConfig, hook } from "../config/index.js";
22
+ import { getRequestContext } from "../http/request-context.js";
23
+ import { publicHost } from "./cache-vary.js";
24
+ import {
25
+ isHtmlCacheFresh,
26
+ isHtmlCacheKeyFresh,
27
+ startEarlyExpirySweep,
28
+ takeInvalidatedPaths,
29
+ takeInvalidatedTargets,
30
+ } from "./html-cache.js";
31
+ import { getDataCacheStats } from "./data-cache.js";
32
+ import { isTransientStatus } from "./upstream-tracking.js";
33
+ import { upstreamCooldownMs } from "./upstream-limiter.js";
34
+
35
+ /**
36
+ * `cache().prewarm.origins` yoksa loopback. Port'suz origin'lere dinleme
37
+ * portu eklenir — `http://tr.localhost` → `http://tr.localhost:3000`.
38
+ *
39
+ * @param {number} port
40
+ * @returns {string[]}
41
+ */
42
+ function resolvePrewarmOrigins(port) {
43
+ const configured = getConfig().prewarm?.origins;
44
+ const list = Array.isArray(configured)
45
+ ? configured.filter((value) => typeof value === "string" && value)
46
+ : [];
47
+
48
+ if (!list.length) return [`http://127.0.0.1:${port}`];
49
+
50
+ return list.map((origin) => {
51
+ try {
52
+ const url = new URL(origin);
53
+ if (!url.port) url.port = String(port);
54
+ return url.origin;
55
+ } catch {
56
+ return origin;
57
+ }
58
+ });
59
+ }
60
+
61
+ /**
62
+ * `vary.host` açıkken ısıtma isteğinin cache anahtarı ziyaretçinin public
63
+ * host'u olsun. Fetch'in kendi `Host` başlığı loopback'tir ve değiştirilemez;
64
+ * `x-forwarded-host` ise `publicHost` tarafından önce okunur. İsteği public
65
+ * origin'e yollamak ALB üzerinden döner ve `h=127.0.0.1` diye ikinci girdi açar.
66
+ *
67
+ * @param {{ get?: (name: string) => string | undefined,
68
+ * headers?: Record<string, unknown> } | undefined} req
69
+ * @returns {string}
70
+ */
71
+ function forwardedHostFrom(req) {
72
+ if (!req) return "";
73
+ try {
74
+ if (!getConfig().cacheVary?.host) return "";
75
+ } catch {
76
+ return "";
77
+ }
78
+ return publicHost(req);
79
+ }
80
+
81
+ /**
82
+ * Tazelik kontrolü, ısıtma isteğinin göreceği host ile aynı öneki arasın.
83
+ *
84
+ * @param {string} host
85
+ * @returns {{ headers: Record<string, string>, get: (name: string) => string | undefined } | undefined}
86
+ */
87
+ function requestForHost(host) {
88
+ if (!host) return undefined;
89
+ return {
90
+ headers: { host, "x-forwarded-host": host },
91
+ get(name) {
92
+ return this.headers[name];
93
+ },
94
+ };
95
+ }
96
+
97
+ /** Klasik turu yöneten env'ler; `onVisit` ile birlikte yasak. */
98
+ const CLASSIC_PREWARM_ENV = [
99
+ "PREWARM_MAX",
100
+ "PREWARM_INTERVAL_SECONDS",
101
+ "PREWARM_DELAY_MS",
102
+ "PREWARM_RETRY_DELAY_MS",
103
+ ];
104
+
105
+ /**
106
+ * onVisit turu sürerken concurrency/rps. Klasik moda dokunmaz (`null`).
107
+ * @type {{ concurrency: number | null, rps: number | null }}
108
+ */
109
+ const visitWarmSettings = { concurrency: null, rps: null };
110
+
111
+ /**
112
+ * Süre dolumu ısıtması klasik turun `rps: 0` (sınırsız) ayarını kullanmaz.
113
+ * Bir TTL uçurumu tek seferde yüzlerce render başlatmasın.
114
+ */
115
+ const EXPIRY_WARM_CONCURRENCY = 1;
116
+ const EXPIRY_WARM_RPS = 2;
117
+
118
+ /** @type {{ concurrency: number | null, rps: number | null }} */
119
+ const expiryWarmSettings = { concurrency: null, rps: null };
120
+
121
+ /**
122
+ * Isıtmanın canlı durumu. Dev araçları bunu okuyup ilerlemeyi gösterir;
123
+ * üretimde kimse okumazsa da maliyeti bir nesnedir.
124
+ */
125
+ export const prewarmProgress = {
126
+ active: false,
127
+ done: 0,
128
+ total: 0,
129
+ ok: 0,
130
+ failed: 0,
131
+ /** @type {number | null} */
132
+ startedAt: null,
133
+ /** @type {number | null} */
134
+ finishedAt: null,
135
+ /**
136
+ * Denenen her yolun sonucu; dev panelindeki Prewarming sekmesi bunu listeler.
137
+ * @type {{ path: string, status: number, ms: number, bytes: number,
138
+ * cache: string | null, error: string | null }[]}
139
+ */
140
+ entries: [],
141
+ };
142
+
143
+ /**
144
+ * Isıtma turu sırasında bastırılan uyarılar: mesaj → kaç kez görüldü.
145
+ *
146
+ * Yüzlerce yolu tarayan bir tur, upstream bir an için tıksırdığında yüzlerce
147
+ * satır loga döküyor ve asıl bilgi (kaç sayfa ısındı) kayboluyor. Tur boyunca
148
+ * mesajlar burada toplanır, tur bitince tek satırda özetlenir. Yol adı
149
+ * anahtara girmez: gruplanabilmesi için mesajın kendisi yeterli, tek bir yolun
150
+ * ayrıntısı zaten `entries` üzerinden dev panelinde görünüyor.
151
+ * @type {Map<string, number>}
152
+ */
153
+ const suppressed = new Map();
154
+
155
+ /**
156
+ * İstek ısıtma turunun kendi isteği mi? Yalnızca tur çalışırken ve istek
157
+ * ısıtmanın user-agent'ıyla geldiğinde doğru; gerçek trafiğin hataları her
158
+ * zaman loglanmaya devam eder.
159
+ *
160
+ * @param {{ get?: (name: string) => string | undefined } | null | undefined} req
161
+ * @returns {boolean}
162
+ */
163
+ export function isPrewarmRequest(req) {
164
+ if (!prewarmProgress.active || !req) return false;
165
+ const ua = req.get?.("user-agent");
166
+ return Boolean(ua) && ua === getConfig().brand.prewarmUserAgent;
167
+ }
168
+
169
+ /**
170
+ * Şu an işlenen istek ısıtma turuna mı ait? `req` elde olmayan derin
171
+ * katmanlar (render, cache) için: yanıt nesnesi istek bağlamında taşınıyor.
172
+ *
173
+ * @returns {boolean}
174
+ */
175
+ function inPrewarmRequest() {
176
+ if (!prewarmProgress.active) return false;
177
+ return isPrewarmRequest(getRequestContext()?.res?.req);
178
+ }
179
+
180
+ /**
181
+ * Isıtma turuna ait bir uyarıyı loglamak yerine sayar.
182
+ *
183
+ * @param {string} message Gruplama anahtarı; yol adı içermemeli.
184
+ * @returns {boolean} `true` ise sayıldı, çağıran taraf loglamamalı.
185
+ */
186
+ export function suppressForPrewarm(message) {
187
+ if (!inPrewarmRequest()) return false;
188
+ suppressed.set(message, (suppressed.get(message) ?? 0) + 1);
189
+ return true;
190
+ }
191
+
192
+ /**
193
+ * Bastırılan bir istek hatasını sayaca ekler. Yığın izi saklanmaz: özet
194
+ * satırının amacı "neyin bozulduğunu" göstermek, hatayı ayıklamak değil.
195
+ *
196
+ * @param {number} status
197
+ * @param {unknown} error
198
+ * @returns {void}
199
+ */
200
+ export function notePrewarmError(status, error) {
201
+ const message = error instanceof Error ? error.message : String(error);
202
+ const key = `${status} ${message.split("\n")[0]}`;
203
+ suppressed.set(key, (suppressed.get(key) ?? 0) + 1);
204
+ }
205
+
206
+ /** @param {unknown} value @param {number} fallback */
207
+ function num(value, fallback) {
208
+ const parsed = Number(value);
209
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
210
+ }
211
+
212
+ /**
213
+ * Ayar sırası: ortam değişkeni → `jskelet.config.mjs` → kod varsayılanı.
214
+ * Env önde, çünkü tek seferlik deneyler config'i düzenlemeden yapılabilsin.
215
+ *
216
+ * @param {string} envKey
217
+ * @param {string} configKey
218
+ * @param {number} fallback
219
+ * @returns {number}
220
+ */
221
+ function setting(envKey, configKey, fallback) {
222
+ if (
223
+ expiryWarmSettings.concurrency != null &&
224
+ configKey === "concurrency"
225
+ ) {
226
+ return expiryWarmSettings.concurrency;
227
+ }
228
+ if (expiryWarmSettings.rps != null && configKey === "rps") {
229
+ return expiryWarmSettings.rps;
230
+ }
231
+ if (
232
+ visitWarmSettings.concurrency != null &&
233
+ configKey === "concurrency"
234
+ ) {
235
+ return visitWarmSettings.concurrency;
236
+ }
237
+ if (visitWarmSettings.rps != null && configKey === "rps") {
238
+ return visitWarmSettings.rps;
239
+ }
240
+ return num(process.env[envKey], num(getConfig().prewarm?.[configKey], fallback));
241
+ }
242
+
243
+ /**
244
+ * @returns {Promise<string[]>}
245
+ */
246
+ async function collectPaths() {
247
+ const { prewarmSkip } = getConfig();
248
+ const paths = await hook("prewarmPaths", []);
249
+
250
+ if (!Array.isArray(paths)) {
251
+ console.warn("[prewarm] hooks.prewarmPaths() must return an array, ignoring it");
252
+ return [];
253
+ }
254
+
255
+ // Tekilleştirme sırayı korur: liste `PREWARM_MAX` ile budandığı için
256
+ // uygulamanın verdiği öncelik sırası anlamlıdır.
257
+ return [...new Set(paths)].filter(
258
+ (candidate) =>
259
+ typeof candidate === "string" &&
260
+ candidate.startsWith("/") &&
261
+ !prewarmSkip.some((prefix) => candidate.startsWith(prefix)),
262
+ );
263
+ }
264
+
265
+ /**
266
+ * `cache().prewarm.priority` desenlerine göre sıralar. Eşleşmeyen yollar
267
+ * listenin sonuna, kendi aralarındaki sırayı koruyarak gider — uygulamanın
268
+ * verdiği sıra hâlâ anlamlı olsun.
269
+ *
270
+ * @param {string[]} paths
271
+ * @returns {{ head: string[], tail: string[] }}
272
+ * `head` öncelikli yollar (her turda ısıtılır), `tail` geri kalan kuyruk
273
+ * (turlar arasında dolaşılır).
274
+ */
275
+ function byPriority(paths) {
276
+ const rules = getConfig().prewarmPriority;
277
+ if (!rules.length) return { head: [], tail: paths };
278
+
279
+ /** @type {string[][]} */
280
+ const buckets = rules.map(() => []);
281
+ /** @type {string[]} */
282
+ const tail = [];
283
+
284
+ for (const candidate of paths) {
285
+ const rank = rules.findIndex((rule) => rule.test(candidate));
286
+ if (rank === -1) tail.push(candidate);
287
+ else buckets[rank].push(candidate);
288
+ }
289
+
290
+ return { head: buckets.flat(), tail };
291
+ }
292
+
293
+ /**
294
+ * Kuyruğun kaldığı yer. Periyodik turlar listeyi baştan ısıtıp aynı ilk
295
+ * `max` yolu tekrar tekrar tazelemesin: her tur bir sonraki dilimi alır ve
296
+ * yeterli tur sonunda liste baştan sona ısınır.
297
+ */
298
+ let queueCursor = 0;
299
+
300
+ /**
301
+ * Bir turda ısıtılacak dilimi seçer: önce `priority` eşleşenler, sonra
302
+ * kuyruğun sırası gelen parçası. Dışa açık olması bilinçli — sıralama ve
303
+ * rotasyon, tur çalışmadan doğrulanabilen tek davranış.
304
+ *
305
+ * @param {string[]} all
306
+ * @param {number} limit
307
+ * @param {boolean} rotate
308
+ * @returns {string[]}
309
+ */
310
+ export function selectPrewarmPaths(all, limit, rotate = true) {
311
+ if (all.length <= limit) return all;
312
+
313
+ const { head, tail } = byPriority(all);
314
+ const selected = head.slice(0, limit);
315
+ const room = limit - selected.length;
316
+ if (room <= 0 || !tail.length) return selected;
317
+
318
+ if (!rotate) return [...selected, ...tail.slice(0, room)];
319
+
320
+ // Dilim listenin sonunu aşarsa başa sarar: kuyruk halkasal dolaşılır.
321
+ const start = queueCursor % tail.length;
322
+ const slice = tail.slice(start, start + room);
323
+ if (slice.length < room) slice.push(...tail.slice(0, room - slice.length));
324
+ queueCursor = (start + room) % tail.length;
325
+
326
+ return [...selected, ...slice];
327
+ }
328
+
329
+ /** @param {number} ms */
330
+ function sleep(ms) {
331
+ return new Promise((resolve) => {
332
+ setTimeout(resolve, ms).unref?.();
333
+ });
334
+ }
335
+
336
+ /**
337
+ * Saniyedeki istek sayısını sınırlar. Fren `concurrency`'den bağımsız
338
+ * olmalı: paralellik gecikmeyi kapatmak için var, kotayı koruyan şey toplam
339
+ * hız. İşçiler aynı sayacı paylaştığı için sıra kimde olursa olsun tur
340
+ * verilen hızın üstüne çıkmaz.
341
+ *
342
+ * @param {number} rps 0 → sınırsız
343
+ * @returns {() => Promise<void>}
344
+ */
345
+ function createPacer(rps) {
346
+ if (!rps) return async () => {};
347
+
348
+ const gap = 1000 / rps;
349
+ let nextSlot = 0;
350
+
351
+ return async () => {
352
+ const now = Date.now();
353
+ const slot = Math.max(now, nextSlot);
354
+ nextSlot = slot + gap;
355
+ if (slot > now) await sleep(slot - now);
356
+ };
357
+ }
358
+
359
+ /**
360
+ * Gate açıkken token taşımayan her isteğe 404 döner. Isıtma kendi sunucusuna
361
+ * istek attığı için token'ı çerez olarak taşımalı; yoksa tüm sayfalar 404
362
+ * alır ve önbellek hiç dolmaz. Gate kapalıyken çerez gönderilmez.
363
+ *
364
+ * @returns {Record<string, string>}
365
+ */
366
+ function devGateHeader() {
367
+ try {
368
+ if (!getConfig().devGate) return {};
369
+ } catch {
370
+ return {};
371
+ }
372
+
373
+ const token = process.env.DEV_TOKEN;
374
+ if (!token) return {};
375
+
376
+ const cookie = getConfig().brand.devTokenCookie;
377
+ return { cookie: `${cookie}=${encodeURIComponent(token)}` };
378
+ }
379
+
380
+ /**
381
+ * @param {string} origin
382
+ * @param {string[]} paths
383
+ * @param {number} concurrency
384
+ * @param {(ok: number, failed: number) => void} [report]
385
+ * Tur ilerlemesini `prewarmProgress`'e yazar. Tekrar turunda sayaçların
386
+ * anlamı değiştiği için çağıran taraf kendi formülünü verir.
387
+ * @param {() => Promise<void>} [pace] İstek başına beklenen hız freni.
388
+ * @param {string} [forwardedHost] `vary.host` için `x-forwarded-host`.
389
+ * @returns {Promise<{ ok: number, failed: number,
390
+ * failures: { path: string, status: number }[] }>}
391
+ * `failures` durum koduyla birlikte döner: tekrar turuna yalnızca geçici
392
+ * hatalar alınıyor, kalıcı olanı yeniden denemek boşa çağrı.
393
+ */
394
+ async function crawl(
395
+ origin,
396
+ paths,
397
+ concurrency,
398
+ report = undefined,
399
+ pace = undefined,
400
+ forwardedHost = "",
401
+ ) {
402
+ const { brand } = getConfig();
403
+ const cacheHeader = brand.cacheHeader.toLowerCase();
404
+
405
+ let index = 0;
406
+ let ok = 0;
407
+ let failed = 0;
408
+ /** @type {{ path: string, status: number }[]} */
409
+ const failures = [];
410
+
411
+ async function worker() {
412
+ while (index < paths.length) {
413
+ const target = paths[index];
414
+ index += 1;
415
+
416
+ if (pace) await pace();
417
+
418
+ const startedAt = Date.now();
419
+
420
+ try {
421
+ const response = await fetch(`${origin}${target}`, {
422
+ headers: {
423
+ // Sıkıştırılmış gövde de cache'lensin.
424
+ "accept-encoding": "br, gzip",
425
+ "user-agent": brand.prewarmUserAgent,
426
+ ...(forwardedHost ? { "x-forwarded-host": forwardedHost } : {}),
427
+ ...devGateHeader(),
428
+ },
429
+ });
430
+ // Gövde okunmadan bağlantı açık kalır.
431
+ const body = await response.arrayBuffer();
432
+ if (response.ok) ok += 1;
433
+ else {
434
+ failed += 1;
435
+ failures.push({ path: target, status: response.status });
436
+ }
437
+
438
+ prewarmProgress.entries.push({
439
+ path: target,
440
+ status: response.status,
441
+ ms: Date.now() - startedAt,
442
+ bytes: body.byteLength,
443
+ cache: response.headers.get(cacheHeader),
444
+ error: response.ok ? null : `HTTP ${response.status}`,
445
+ });
446
+ } catch (error) {
447
+ failed += 1;
448
+ // Yanıt hiç gelmedi: ağ hatası her zaman geçici, tekrar denenir.
449
+ failures.push({ path: target, status: 0 });
450
+ prewarmProgress.entries.push({
451
+ path: target,
452
+ status: 0,
453
+ ms: Date.now() - startedAt,
454
+ bytes: 0,
455
+ cache: null,
456
+ error: error instanceof Error ? error.message : String(error),
457
+ });
458
+ }
459
+
460
+ if (report) {
461
+ report(ok, failed);
462
+ } else {
463
+ prewarmProgress.done = ok + failed;
464
+ prewarmProgress.ok = ok;
465
+ prewarmProgress.failed = failed;
466
+ }
467
+ }
468
+ }
469
+
470
+ await Promise.all(
471
+ Array.from({ length: Math.min(concurrency, paths.length) }, worker),
472
+ );
473
+
474
+ return { ok, failed, failures };
475
+ }
476
+
477
+ /**
478
+ * Tekrar turuna girecek yollar. Yalnızca geçici hatalar: `400`/`403`/`404` gibi
479
+ * deterministik cevaplar tekrar denemekle düzelmez ve o çağrılar kotadan
480
+ * karşılıksız yiyor. Sınıflandırma render tarafıyla aynı listeden
481
+ * (`isTransientStatus`), yoksa iki yer birbirinden kayar.
482
+ *
483
+ * @param {{ path: string, status: number }[]} failures
484
+ * @returns {string[]}
485
+ */
486
+ function retryablePaths(failures) {
487
+ return failures
488
+ .filter((failure) => isTransientStatus(failure.status))
489
+ .map((failure) => failure.path);
490
+ }
491
+
492
+ /**
493
+ * Tekrar turundan önce beklenecek süre.
494
+ *
495
+ * Sabit bekleme yanlış soruyu cevaplıyordu: doğru süreyi upstream biliyor.
496
+ * Hız freni açıkken `Retry-After` ya da devre kesicinin soğuma süresi zaten
497
+ * elimizde; 10 saniye kapalı kalacak bir kesiciden 2 saniye sonra tekrar
498
+ * denemek, aynı 429'u peşin peşin almak demek.
499
+ *
500
+ * @returns {number} ms
501
+ */
502
+ function retryDelayMs() {
503
+ const configured = setting("PREWARM_RETRY_DELAY_MS", "retryDelayMs", 2000);
504
+ const cooldown = upstreamCooldownMs();
505
+
506
+ // Fren kapalıysa `cooldown` 0 olur ve davranış eskisi gibi kalır. Üst sınır
507
+ // turun sonsuza kadar açık kalmasını engelliyor.
508
+ return Math.min(Math.max(configured, cooldown), 60_000);
509
+ }
510
+
511
+ /**
512
+ * Turun veri önbelleği üzerinden upstream'e ne kadar dokunduğunu özetler.
513
+ *
514
+ * @param {ReturnType<typeof getDataCacheStats>} before Tur başındaki sayaçlar.
515
+ * @returns {string} Okunacak bir şey yoksa boş dize.
516
+ */
517
+ function upstreamUsage(before) {
518
+ const after = getDataCacheStats();
519
+ const reads = after.reads - before.reads;
520
+ if (reads <= 0) return "";
521
+
522
+ const produced = after.produced - before.produced;
523
+ const coalesced = after.coalesced - before.coalesced;
524
+ const shared = after.shared - before.shared;
525
+ const served = reads - produced;
526
+ const ratio = Math.round((served / reads) * 100);
527
+
528
+ return (
529
+ `${produced} upstream call${produced === 1 ? "" : "s"} for ${reads} data read${reads === 1 ? "" : "s"} ` +
530
+ `(${ratio}% from the data cache` +
531
+ `${coalesced ? `, ${coalesced} coalesced` : ""}` +
532
+ `${shared ? `, ${shared} from the shared tier` : ""})`
533
+ );
534
+ }
535
+
536
+ /**
537
+ * @param {{ origin: string, quiet?: boolean, paths?: string[],
538
+ * forwardedHost?: string }} options
539
+ * `paths` verilirse hook çağrılmaz, yalnızca o yollar ısıtılır (dev
540
+ * panelindeki "tekrar dene" bunu kullanır). `forwardedHost` loopback
541
+ * isteğine public host'u taşır; cache anahtarı `h=127.0.0.1` olmasın.
542
+ * @returns {Promise<{ ok: number, failed: number, total: number, elapsed: number }>}
543
+ */
544
+ export async function prewarm({ origin, quiet = false, paths: only, forwardedHost = "" }) {
545
+ const started = Date.now();
546
+ const limit = setting("PREWARM_MAX", "max", 400);
547
+ const isDev = process.env.NODE_ENV === "development";
548
+
549
+ // Dev'de tek işçi: tarama, o an tarayıcıda açtığın sayfanın render'ıyla CPU
550
+ // için yarışmasın.
551
+ const concurrency = setting(
552
+ "PREWARM_CONCURRENCY",
553
+ "concurrency",
554
+ isDev ? 1 : 4,
555
+ );
556
+
557
+ // Render tek bir olay döngüsünde çalışıyor: aralıksız bir tur, geliştirme
558
+ // sırasında sayfa isteklerini ve dev panelinin kanalını arkasında bekletiyor.
559
+ // Dev'de varsayılan bir hız freni bu yüzden var; üretimde ısıtma bir kez
560
+ // olup bittiği için fren yalnızca istenirse (`prewarm.rps`) devreye girer.
561
+ // onVisit turlarında `visitWarmSettings.rps` `setting()` üzerinden iner.
562
+ const rps = setting("PREWARM_RPS", "rps", isDev ? 4 : 0);
563
+ const pace = createPacer(rps);
564
+
565
+ const all = only?.length ? only : await collectPaths();
566
+ // Elle verilen liste budanmaz ve sıralanmaz: çağıran tam olarak neyi
567
+ // istediğini biliyor (dev panelindeki "tekrar dene" bunu kullanır).
568
+ const selected = only?.length
569
+ ? all
570
+ : selectPrewarmPaths(all, limit, getConfig().prewarm?.rotate !== false);
571
+
572
+ // Invalidate edilmiş sayfalar kuyruğun önüne geçer: "içerik güncellendi"
573
+ // bilgisi geldiğinde sayfa, ziyaretçi gelmesini beklemeden tazelenir. Bu
574
+ // yollar `max` bütçesinin dışında tutulur — sayıları zaten gerçekleşen
575
+ // invalidation kadar ve rotasyonun sırasını bozmaları istenmez.
576
+ const pending = only?.length ? [] : takeInvalidatedPaths();
577
+ const paths = pending.length
578
+ ? [...new Set([...pending, ...selected])]
579
+ : selected;
580
+
581
+ Object.assign(prewarmProgress, {
582
+ active: true,
583
+ done: 0,
584
+ total: paths.length,
585
+ ok: 0,
586
+ failed: 0,
587
+ startedAt: started,
588
+ finishedAt: null,
589
+ entries: [],
590
+ });
591
+ suppressed.clear();
592
+
593
+ // Kotanın gerçekten ne kadar harcandığı ancak veri önbelleğinden görülür:
594
+ // 400 sayfalık bir tur, ortak bir uç için tek çağrı da yapabilir dört yüz de.
595
+ const dataBefore = getDataCacheStats();
596
+
597
+ let ok = 0;
598
+ let failed = 0;
599
+ let recovered = 0;
600
+ let skippedRetry = 0;
601
+ try {
602
+ /** @type {{ path: string, status: number }[]} */
603
+ let failures;
604
+ ({ ok, failed, failures } = await crawl(
605
+ origin,
606
+ paths,
607
+ concurrency,
608
+ undefined,
609
+ pace,
610
+ forwardedHost,
611
+ ));
612
+
613
+ // Hatalar çoğunlukla upstream rate limit'i (429): ilk tur yüzlerce sayfayı
614
+ // aynı anda çekerken API'yi zorluyor. Tek seri tekrar turu bu sayfaların
615
+ // önbelleğe girmesini sağlıyor; aksi hâlde ziyaretçi soğuk render'ı öder.
616
+ const retryPaths = retryablePaths(failures);
617
+ skippedRetry = failures.length - retryPaths.length;
618
+
619
+ if (retryPaths.length) {
620
+ const firstOk = ok;
621
+ const firstFailed = failed;
622
+
623
+ await sleep(retryDelayMs());
624
+
625
+ const retry = await crawl(
626
+ origin,
627
+ retryPaths,
628
+ 1,
629
+ (retriedOk) => {
630
+ // Tekrar turunda her başarı bir hatayı başarıya çevirir.
631
+ prewarmProgress.ok = firstOk + retriedOk;
632
+ prewarmProgress.failed = firstFailed - retriedOk;
633
+ },
634
+ pace,
635
+ forwardedHost,
636
+ );
637
+ recovered = retry.ok;
638
+ ok += retry.ok;
639
+ failed -= retry.ok;
640
+ }
641
+ } finally {
642
+ prewarmProgress.active = false;
643
+ prewarmProgress.finishedAt = Date.now();
644
+ }
645
+ const elapsed = Date.now() - started;
646
+
647
+ if (!quiet && paths.length) {
648
+ const skipped = all.length - selected.length;
649
+ // Rotasyon açıkken sınırın dışında kalan yollar kaybolmuyor, bir sonraki
650
+ // tura kalıyor; log bunu ayırt etmeli, yoksa "400 yol atlandı" satırı
651
+ // hatalı bir kurulum sanılıyor.
652
+ const rotate = !only?.length && getConfig().prewarm?.rotate !== false;
653
+ console.log(
654
+ `[prewarm] warmed ${ok}/${paths.length} pages` +
655
+ `${pending.length ? `, ${pending.length} invalidated` : ""}` +
656
+ `${failed ? `, ${failed} failed` : ""}` +
657
+ `${recovered ? `, ${recovered} recovered on the retry pass` : ""}` +
658
+ `${skippedRetry ? `, ${skippedRetry} not retried (permanent)` : ""}` +
659
+ `${skipped > 0 ? `, ${skipped} ${rotate ? "deferred to the next pass" : "over the limit"}` : ""}` +
660
+ ` (${(elapsed / 1000).toFixed(1)}s)`,
661
+ );
662
+
663
+ // Turun upstream'e ne kadar dokunduğu. Asıl karar bu satıra bakılarak
664
+ // veriliyor: oran düşükse çözüm hız freni değil, `withDataCache` TTL'ini
665
+ // tur aralığından uzun tutmak — fren çağrıları yavaşlatır, sayısını
666
+ // azaltmaz.
667
+ const upstreamCalls = upstreamUsage(dataBefore);
668
+ if (upstreamCalls) console.log(`[prewarm] ${upstreamCalls}`);
669
+
670
+ // Tur boyunca bastırılan hatalar: en sık görülenler önce, liste uzarsa
671
+ // kalanı tek satırda toplanır. Amaç, logu şişirmeden "ne bozuldu"yu
672
+ // görünür tutmak.
673
+ if (suppressed.size) {
674
+ const ranked = [...suppressed.entries()].sort((a, b) => b[1] - a[1]);
675
+ const shown = ranked.slice(0, 5);
676
+ const rest = ranked.slice(shown.length).reduce((sum, [, n]) => sum + n, 0);
677
+ const total = ranked.reduce((sum, [, n]) => sum + n, 0);
678
+
679
+ console.warn(
680
+ `[prewarm] ${total} problem${total === 1 ? " was" : "s were"} not logged individually:\n` +
681
+ shown.map(([message, n]) => ` ${n}× ${message}`).join("\n") +
682
+ (rest ? `\n … ${rest} more in ${ranked.length - shown.length} other kinds` : ""),
683
+ );
684
+ }
685
+ }
686
+
687
+ return { ok, failed, total: paths.length, elapsed };
688
+ }
689
+
690
+ /**
691
+ * Speculation Rules `href_matches` benzeri dışlama: tam yol veya `/*` öneki.
692
+ *
693
+ * @param {string} pathname
694
+ * @param {string[]} patterns
695
+ * @returns {boolean}
696
+ */
697
+ function matchesHrefExclude(pathname, patterns) {
698
+ for (const pattern of patterns) {
699
+ if (typeof pattern !== "string" || !pattern) continue;
700
+ if (pattern.endsWith("/*")) {
701
+ const base = pattern.slice(0, -2);
702
+ if (pathname === base || pathname.startsWith(`${base}/`)) return true;
703
+ continue;
704
+ }
705
+ if (pathname === pattern) return true;
706
+ }
707
+ return false;
708
+ }
709
+
710
+ /**
711
+ * HTML içindeki aynı-origin `<a href>` yollarını DOM sırasıyla (üstten alta)
712
+ * toplar. Speculation Rules ile aynı muafiyetler: `nofollow`, `_blank`,
713
+ * `data-no-prefetch`, `prewarmSkip`, `navigation.exclude`.
714
+ *
715
+ * @param {string} html
716
+ * @param {{ limit?: number, basePath?: string }} [options]
717
+ * @returns {string[]}
718
+ */
719
+ export function extractSameOriginLinks(html, options = {}) {
720
+ if (typeof html !== "string" || !html) return [];
721
+
722
+ const limit = Math.max(1, Math.floor(Number(options.limit) || 20));
723
+ const { prewarmSkip, navigation } = getConfig();
724
+ const exclude = navigation?.exclude ?? [];
725
+
726
+ /** @type {string[]} */
727
+ const links = [];
728
+ const seen = new Set();
729
+ const tagRe = /<a\b([^>]*)>/gi;
730
+ let match;
731
+
732
+ while ((match = tagRe.exec(html)) !== null && links.length < limit) {
733
+ const attrs = match[1];
734
+ if (/\btarget\s*=\s*(?:"_blank"|'_blank'|_blank)(?=[\s>]|$)/i.test(attrs)) {
735
+ continue;
736
+ }
737
+ if (/\bdata-no-prefetch\b/i.test(attrs)) continue;
738
+
739
+ const relMatch = attrs.match(
740
+ /\brel\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+))/i,
741
+ );
742
+ const rel = relMatch?.[1] ?? relMatch?.[2] ?? relMatch?.[3] ?? "";
743
+ if (/\bnofollow\b/i.test(rel)) continue;
744
+
745
+ const hrefMatch = attrs.match(
746
+ /\bhref\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+))/i,
747
+ );
748
+ const href = hrefMatch?.[1] ?? hrefMatch?.[2] ?? hrefMatch?.[3];
749
+ if (!href) continue;
750
+
751
+ const path = normalizeWarmPath(href, options.basePath);
752
+ if (!path) continue;
753
+ if (prewarmSkip.some((prefix) => path.startsWith(prefix))) continue;
754
+ if (matchesHrefExclude(path, exclude)) continue;
755
+ if (seen.has(path)) continue;
756
+
757
+ seen.add(path);
758
+ links.push(path);
759
+ }
760
+
761
+ return links;
762
+ }
763
+
764
+ /**
765
+ * @param {string} href
766
+ * @param {string} [basePath] Ziyaret edilen sayfa; göreli href çözümü için.
767
+ * @returns {string | null}
768
+ */
769
+ function normalizeWarmPath(href, basePath = "/") {
770
+ const trimmed = href.trim();
771
+ if (!trimmed || trimmed.startsWith("#")) return null;
772
+ if (/^(mailto|tel|javascript|data):/i.test(trimmed)) return null;
773
+
774
+ try {
775
+ const basePathname = basePath.startsWith("/") ? basePath : `/${basePath}`;
776
+ const url = new URL(trimmed, `https://warm.invalid${basePathname}`);
777
+ // Dış origin veya `https://…` mutlak linkler elenir; yalnızca site-içi
778
+ // path / göreli href ısınır.
779
+ if (url.origin !== "https://warm.invalid") return null;
780
+ return url.pathname || "/";
781
+ } catch {
782
+ return null;
783
+ }
784
+ }
785
+
786
+ /** @type {string | null} */
787
+ let visitOrigin = null;
788
+ /**
789
+ * onVisit turunun `x-forwarded-host` değeri. Boşsa vary kapalıdır ve
790
+ * loopback anahtarı doğrudur.
791
+ * @type {string}
792
+ */
793
+ let visitForwardedHost = "";
794
+ /** @type {string[]} */
795
+ const visitPending = [];
796
+ /** @type {Set<string>} */
797
+ const visitQueued = new Set();
798
+ let visitDraining = false;
799
+
800
+ /**
801
+ * Ziyaret ısıtması açık mı? `PREWARM=0` her iki modu da keser.
802
+ *
803
+ * @returns {boolean}
804
+ */
805
+ export function isOnVisitPrewarm() {
806
+ if (process.env.PREWARM === "0") return false;
807
+ try {
808
+ return getConfig().prewarm?.onVisit?.enabled === true;
809
+ } catch {
810
+ return false;
811
+ }
812
+ }
813
+
814
+ /**
815
+ * Yanıt gövdesindeki linkleri soğuksa kuyruğa alır. İstek yolunu bloklamamak
816
+ * için `route()` bunu `queueMicrotask` ile çağırır.
817
+ *
818
+ * @param {string} html
819
+ * @param {{ path: string, req?: { get?: (name: string) => string | undefined,
820
+ * headers?: Record<string, unknown> } }} context
821
+ * @returns {void}
822
+ */
823
+ export function noteVisitWarm(html, context) {
824
+ if (!isOnVisitPrewarm() || !visitOrigin) return;
825
+ if (!context?.path || typeof html !== "string") return;
826
+
827
+ const ua =
828
+ context.req?.get?.("user-agent") ??
829
+ /** @type {string | undefined} */ (context.req?.headers?.["user-agent"]);
830
+ if (ua && ua === getConfig().brand.prewarmUserAgent) return;
831
+
832
+ // Public origin'e çıkmak yerine loopback'te kal, host'u başlıkla taşı.
833
+ visitForwardedHost = forwardedHostFrom(context.req);
834
+ const freshReq = requestForHost(visitForwardedHost);
835
+
836
+ const perPage = Number(getConfig().prewarm?.onVisit?.perPage) || 20;
837
+ const links = extractSameOriginLinks(html, {
838
+ limit: perPage,
839
+ basePath: context.path,
840
+ });
841
+
842
+ for (const path of links) {
843
+ if (path === context.path) continue;
844
+ if (isHtmlCacheFresh(path, freshReq)) continue;
845
+ if (!enqueueWarmPath(path)) break;
846
+ }
847
+
848
+ enqueueInvalidatedForVisit();
849
+ if (visitPending.length) void drainVisitWarm();
850
+ }
851
+
852
+ /**
853
+ * Kuyruk tavanı dolunca yeni yol alınmaz. Birikmiş yüzlerce link, rps
854
+ * freni olsa bile task'ı uzun süre meşgul eder.
855
+ *
856
+ * @param {string} path
857
+ * @returns {boolean} Kuyruğa girdi mi.
858
+ */
859
+ function enqueueWarmPath(path) {
860
+ if (visitPending.length >= ON_VISIT_QUEUE_MAX) return false;
861
+ if (visitQueued.has(path)) return false;
862
+ visitQueued.add(path);
863
+ visitPending.push(path);
864
+ return true;
865
+ }
866
+
867
+ /**
868
+ * Invalidate edilmiş yolları da kuyruğa alır (klasik turdaki
869
+ * `takeInvalidatedPaths` karşılığı).
870
+ *
871
+ * @returns {void}
872
+ */
873
+ function enqueueInvalidatedForVisit() {
874
+ // Başka locale'in anahtarını bu ziyaretçinin host'uyla ısıtma; süre
875
+ // dolumu onları kendi `x-forwarded-host` değeriyle alır.
876
+ for (const target of takeInvalidatedTargets(visitForwardedHost || undefined)) {
877
+ if (isHtmlCacheFresh(target.path, requestForHost(target.host || visitForwardedHost))) {
878
+ continue;
879
+ }
880
+ if (!enqueueWarmPath(target.path)) break;
881
+ }
882
+ }
883
+
884
+ async function drainVisitWarm() {
885
+ if (visitDraining || !visitOrigin) return;
886
+ visitDraining = true;
887
+
888
+ const onVisit = getConfig().prewarm?.onVisit ?? {};
889
+
890
+ try {
891
+ while (visitPending.length) {
892
+ enqueueInvalidatedForVisit();
893
+
894
+ const batch = visitPending.splice(0, 32);
895
+ for (const path of batch) visitQueued.delete(path);
896
+
897
+ const cold = batch.filter(
898
+ (path) => !isHtmlCacheFresh(path, requestForHost(visitForwardedHost)),
899
+ );
900
+ if (!cold.length) continue;
901
+
902
+ // Klasik `prewarm()` turunu yeniden kullan: retry, progress, UA aynı.
903
+ // onVisit.concurrency / rps config tavanına çekilmiş hâlde iner.
904
+ visitWarmSettings.concurrency = onVisit.concurrency;
905
+ visitWarmSettings.rps = onVisit.rps;
906
+
907
+ try {
908
+ await prewarm({
909
+ origin: visitOrigin,
910
+ paths: cold,
911
+ quiet: true,
912
+ forwardedHost: visitForwardedHost,
913
+ });
914
+ } catch (error) {
915
+ console.error("[prewarm] onVisit warm failed", error);
916
+ } finally {
917
+ visitWarmSettings.concurrency = null;
918
+ visitWarmSettings.rps = null;
919
+ }
920
+ }
921
+ } finally {
922
+ visitDraining = false;
923
+ if (visitPending.length) void drainVisitWarm();
924
+ }
925
+ }
926
+
927
+ /**
928
+ * Açılışta ısıtmayı tetikler. `listen` geri çağrısından çağrılır.
929
+ * `onVisit` modunda klasik zamanlayıcı yok; yine de erken-TTL invalidation
930
+ * drain'i çalışır — soft-bayatlayan sweeper'ın kuyruğu boşalmasın.
931
+ *
932
+ * @param {{ port: number }} options
933
+ * @returns {void}
934
+ */
935
+ export function startPrewarm({ port }) {
936
+ const config = getConfig();
937
+ if (process.env.PREWARM === "0") return;
938
+
939
+ const origins = resolvePrewarmOrigins(port);
940
+ const origin = origins[0];
941
+
942
+ // Klasik `prewarmPaths` olmasa da TTL öncesi soft-bayatlayan girdiler
943
+ // HTTP ile ısıtılsın. `PREWARM=0` yukarıda her şeyi keser.
944
+ startEarlyExpirySweep();
945
+ startExpiryWarmDrain(origins);
946
+
947
+ if (config.prewarm?.onVisit?.enabled) {
948
+ const classicEnv = CLASSIC_PREWARM_ENV.filter((key) => process.env[key]);
949
+ if (classicEnv.length) {
950
+ throw new Error(
951
+ "[prewarm] onVisit mode cannot be used with " +
952
+ `${classicEnv.join(", ")}. Those env vars belong to classic prewarm.`,
953
+ );
954
+ }
955
+
956
+ visitOrigin = origin;
957
+ console.log(
958
+ `[prewarm] onVisit mode — warming links from each public page response`,
959
+ );
960
+ return;
961
+ }
962
+
963
+ if (process.env.PREWARM !== "1" && config.prewarm?.enabled === false) return;
964
+ // Isıtacak yol bildirmeyen bir projede klasik tur zamanlayıcısı gerekmez;
965
+ // expiry drain yine de yukarıda kuruldu.
966
+ if (typeof config.hooks?.prewarmPaths !== "function") return;
967
+
968
+ const isDev = process.env.NODE_ENV === "development";
969
+
970
+ // Hız frenli bir tur `intervalSeconds`'tan uzun sürebilir; üst üste binen
971
+ // turlar `prewarmProgress`'i bozar ve upstream'e iki kat yük bindirir.
972
+ let running = false;
973
+ const run = async () => {
974
+ if (running) return;
975
+ running = true;
976
+ try {
977
+ // `vary.host` açıkken her origin ayrı anahtar ısıtır.
978
+ for (const next of origins) {
979
+ await prewarm({ origin: next });
980
+ }
981
+ } catch (error) {
982
+ console.error("[prewarm] failed", error);
983
+ } finally {
984
+ running = false;
985
+ }
986
+ };
987
+
988
+ // Isıtma ilk isteklerle yarışmasın diye gecikmeyle başlar. Dev'de gecikme
989
+ // daha uzun: dosya kaydı süreci yeniden başlattığı için zamanlayıcı da
990
+ // ölür; yalnızca sunucu bir süre sakin kalınca ısınır.
991
+ const delay = setting("PREWARM_DELAY_MS", "delayMs", isDev ? 3000 : 500);
992
+ setTimeout(() => void run(), delay).unref();
993
+
994
+ // Girdiler `revalidate` ile yaşlanır; stale-while-revalidate sayesinde
995
+ // ziyaretçi beklemez. Periyodik tur, hiç ziyaret edilmeyen sayfaları da
996
+ // sıcak tutmak isteyen kurulumlar için opsiyoneldir.
997
+ // `rotate` ile birlikte bu ayar "damla damla ısıtma"ya dönüşür: her tur
998
+ // kuyruğun bir dilimini alır, yeterli tur sonunda liste baştan sona ısınır.
999
+ const interval = setting("PREWARM_INTERVAL_SECONDS", "intervalSeconds", 0);
1000
+ if (interval > 0) setInterval(() => void run(), interval * 1000).unref();
1001
+ }
1002
+
1003
+ /** @type {string[]} */
1004
+ let expiryOrigins = [];
1005
+
1006
+ /** @type {boolean} */
1007
+ let expiryDraining = false;
1008
+
1009
+ /** @type {ReturnType<typeof setInterval> | null} */
1010
+ let expiryDrainTimer = null;
1011
+
1012
+ /**
1013
+ * Soft-bayat / invalidate kuyruğunu periyodik boşaltır. Klasik tur ve onVisit
1014
+ * aynı kuyruğu da okur; bu drain `prewarmPaths` yokken de çalışır.
1015
+ *
1016
+ * @param {string[]} origins
1017
+ * @returns {void}
1018
+ */
1019
+ function startExpiryWarmDrain(origins) {
1020
+ expiryOrigins = origins.length ? origins : [];
1021
+ if (expiryDrainTimer) return;
1022
+ expiryDrainTimer = setInterval(() => {
1023
+ void drainExpiryWarm();
1024
+ }, 1000);
1025
+ expiryDrainTimer.unref();
1026
+ }
1027
+
1028
+ /**
1029
+ * @returns {Promise<void>}
1030
+ */
1031
+ async function drainExpiryWarm() {
1032
+ if (expiryDraining || !expiryOrigins.length) return;
1033
+
1034
+ const targets = takeInvalidatedTargets();
1035
+ if (!targets.length) return;
1036
+
1037
+ expiryDraining = true;
1038
+ expiryWarmSettings.concurrency = EXPIRY_WARM_CONCURRENCY;
1039
+ expiryWarmSettings.rps = EXPIRY_WARM_RPS;
1040
+ try {
1041
+ /** @type {Map<string, string[]>} */
1042
+ const byHost = new Map();
1043
+ for (const target of targets) {
1044
+ if (isHtmlCacheKeyFresh(target.key)) continue;
1045
+ const host = target.host || "";
1046
+ const list = byHost.get(host);
1047
+ if (list) list.push(target.path);
1048
+ else byHost.set(host, [target.path]);
1049
+ }
1050
+ if (!byHost.size) return;
1051
+
1052
+ // Vary kapalı: anahtarda host yok, her yapılandırılmış origin ısınır.
1053
+ const bare = byHost.get("") ?? [];
1054
+ if (bare.length && byHost.size === 1) {
1055
+ for (const origin of expiryOrigins) {
1056
+ await prewarm({ origin, paths: bare, quiet: true });
1057
+ }
1058
+ return;
1059
+ }
1060
+
1061
+ // Host'lu anahtar loopback'e gider. `x-forwarded-host` cache anahtarını
1062
+ // public host yapar; aksi hâlde `h=127.0.0.1|/yol` diye ikinci girdi
1063
+ // açılır ve `maxEntries` slotunu yer.
1064
+ const origin = expiryOrigins[0];
1065
+ for (const [host, paths] of byHost) {
1066
+ if (!paths.length) continue;
1067
+ if (!host) {
1068
+ for (const next of expiryOrigins) {
1069
+ await prewarm({ origin: next, paths, quiet: true });
1070
+ }
1071
+ continue;
1072
+ }
1073
+ await prewarm({ origin, paths, quiet: true, forwardedHost: host });
1074
+ }
1075
+ } catch (error) {
1076
+ console.error("[prewarm] expiry warm failed", error);
1077
+ } finally {
1078
+ expiryWarmSettings.concurrency = null;
1079
+ expiryWarmSettings.rps = null;
1080
+ expiryDraining = false;
1081
+ }
1082
+ }