jskelet 0.1.7 → 0.2.0

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.
@@ -14,16 +14,45 @@
14
14
  * `invalidateHtmlCache()` ikisinin arasını açar ve varsayılan davranışı
15
15
  * **bayatlatmaktır**: girdi silinmez, süresi geçmiş sayılır. Ziyaretçi eski
16
16
  * HTML'i beklemeden alır, tazeleme arkada tek seferde koşar.
17
+ *
18
+ * ## Paylaşımlı kademe
19
+ *
20
+ * `cache.redis` açıkken store'un ikinci bir kademesi olur. Bellek içi store
21
+ * (L1) **birincil kalır**: `read()` senkron, sıkıştırılmış gövdeler girdiyle
22
+ * birlikte ve tutarlılık makinesi (`tokens`, `purgedDeps`) tek proseste. Redis
23
+ * yalnızca L1'de bulunmayan bir yol için render'ı atlatır ve invalidation'ı
24
+ * diğer node'lara duyurur. Redis erişilemez olduğunda bu modül birebir eskisi
25
+ * gibi çalışır.
17
26
  */
18
27
 
19
28
  import { getConfig } from "../config/index.js";
20
29
  import { DEFAULT_HTML_CACHE_MAX_ENTRIES } from "../config/defaults.js";
21
30
  import { collectDependencies } from "./cache-deps.js";
22
31
  import { compilePattern, matchPattern } from "../config/pattern.js";
32
+ import {
33
+ cacheKey,
34
+ onCacheEvent,
35
+ publishCacheEvent,
36
+ redisDrop,
37
+ redisDropMatching,
38
+ redisGetJson,
39
+ redisSetJson,
40
+ redisShares,
41
+ redisSharesEncoded,
42
+ } from "./redis.js";
23
43
 
24
44
  /**
45
+ * `storedAt`: girdinin üretildiği an. Paylaşımlı kademeden gelen bir girdiyi
46
+ * kabul etmeden önce "bu render yerel bir purge'den önce mi başladı" sorusu
47
+ * yine sorulur; cevabı bu alan taşıyor.
48
+ *
49
+ * `sharedEncodings`: Redis'e en son kaç sıkıştırılmış gövde yazıldığı.
50
+ * `encoded` haritası yanıt yolunda (`sendHtml`) doluyor, yani yazma anında
51
+ * boş; `storeEncoded` açıkken harita büyüdüğünde girdi yeniden paylaşılır.
52
+ *
25
53
  * @typedef {{ html: string, status: number, expiresAt: number,
26
- * staleUntil: number, encoded: Map<string, Buffer>, deps: Set<string> }} HtmlEntry
54
+ * staleUntil: number, encoded: Map<string, Buffer>, deps: Set<string>,
55
+ * storedAt: number, sharedEncodings: number }} HtmlEntry
27
56
  */
28
57
 
29
58
  /**
@@ -170,6 +199,13 @@ function read(key) {
170
199
  store.delete(key);
171
200
  store.set(key, entry);
172
201
 
202
+ // `encoded` yanıt yolunda dolduğu için yazma anında paylaşılamıyor. Kontrol
203
+ // yalnızca `storeEncoded` açıkken yapılır; kapalıyken (varsayılan) bu satır
204
+ // tek bir karşılaştırmaya bile girmez.
205
+ if (redisSharesEncoded() && entry.encoded.size !== entry.sharedEncodings) {
206
+ share(key, entry);
207
+ }
208
+
173
209
  return {
174
210
  html: entry.html,
175
211
  status: entry.status,
@@ -178,6 +214,93 @@ function read(key) {
178
214
  };
179
215
  }
180
216
 
217
+ /**
218
+ * Girdiyi paylaşımlı kademeye yazar. Ateşle-unut: yanıt yolunda beklenmez,
219
+ * L1 kopyası bu isteği zaten karşılıyor.
220
+ *
221
+ * @param {string} key
222
+ * @param {HtmlEntry} entry
223
+ */
224
+ function share(key, entry) {
225
+ if (!redisShares("html")) return;
226
+
227
+ const ttlMs = entry.staleUntil - Date.now();
228
+ if (ttlMs <= 0) return;
229
+
230
+ /** @type {Record<string, unknown>} */
231
+ const payload = {
232
+ html: entry.html,
233
+ status: entry.status,
234
+ storedAt: entry.storedAt,
235
+ expiresAt: entry.expiresAt,
236
+ staleUntil: entry.staleUntil,
237
+ deps: [...entry.deps],
238
+ };
239
+
240
+ if (redisSharesEncoded() && entry.encoded.size) {
241
+ /** @type {Record<string, string>} */
242
+ const encoded = {};
243
+ for (const [encoding, buffer] of entry.encoded) {
244
+ encoded[encoding] = buffer.toString("base64");
245
+ }
246
+ payload.encoded = encoded;
247
+ entry.sharedEncodings = entry.encoded.size;
248
+ }
249
+
250
+ redisSetJson(cacheKey("html", key), payload, ttlMs);
251
+ }
252
+
253
+ /**
254
+ * Paylaşımlı kademeden okur ve L1 girdisine çevirir.
255
+ *
256
+ * Yalnızca **taze** girdi kabul edilir: bayat bir kopyayı L1'e almak
257
+ * tazelemeyi sonsuza kadar ertelerdi — girdi bayat kalır, her tazeleme turu
258
+ * yine Redis'i okur ve `producer` hiç çalışmaz.
259
+ *
260
+ * @param {string} key
261
+ * @returns {Promise<HtmlEntry | null>}
262
+ */
263
+ async function readShared(key) {
264
+ if (!redisShares("html")) return null;
265
+
266
+ const payload = await redisGetJson(cacheKey("html", key));
267
+ if (!payload || typeof payload.html !== "string") return null;
268
+ if (typeof payload.expiresAt !== "number" || Date.now() >= payload.expiresAt) {
269
+ return null;
270
+ }
271
+
272
+ const deps = new Set(Array.isArray(payload.deps) ? payload.deps.map(String) : []);
273
+ const storedAt = Number(payload.storedAt) || 0;
274
+
275
+ // Uzak girdi de yerel purge geçmişine takılır: bu proseste düşürülmüş bir
276
+ // veriyi okumuş HTML'i geri almak, az önce yapılan invalidation'ı iptal
277
+ // etmek olurdu.
278
+ if (readsPurgedData(deps, storedAt)) return null;
279
+
280
+ /** @type {Map<string, Buffer>} */
281
+ const encoded = new Map();
282
+ if (payload.encoded && typeof payload.encoded === "object") {
283
+ for (const [encoding, base64] of Object.entries(payload.encoded)) {
284
+ if (typeof base64 === "string") {
285
+ encoded.set(encoding, Buffer.from(base64, "base64"));
286
+ }
287
+ }
288
+ }
289
+
290
+ return {
291
+ html: payload.html,
292
+ status: Number(payload.status) || 200,
293
+ encoded,
294
+ // Mutlak zamanlar korunur: TTL'i yeniden başlatmak, girdinin node'dan
295
+ // node'a atlayarak süresiz tazelik kazanması demek.
296
+ expiresAt: payload.expiresAt,
297
+ staleUntil: Number(payload.staleUntil) || payload.expiresAt,
298
+ deps,
299
+ storedAt,
300
+ sharedEncodings: encoded.size,
301
+ };
302
+ }
303
+
181
304
  /**
182
305
  * @param {string} key
183
306
  * @param {{ html: string, status: number }} value
@@ -187,11 +310,8 @@ function read(key) {
187
310
  function write(key, value, ttlSeconds, deps = null) {
188
311
  const now = Date.now();
189
312
 
190
- // Aynı anahtarın eski girdisi ters indekste kalmasın: bağımlılıklar
191
- // tazelemeden tazelemeye değişebilir.
192
- drop(key);
193
-
194
- store.set(key, {
313
+ /** @type {HtmlEntry} */
314
+ const entry = {
195
315
  html: value.html,
196
316
  status: value.status,
197
317
  // Sıkıştırılmış gövdeler HTML ile aynı ömrü paylaşır: aynı sayfa her
@@ -200,14 +320,32 @@ function write(key, value, ttlSeconds, deps = null) {
200
320
  expiresAt: now + ttlSeconds * 1000,
201
321
  staleUntil: now + ttlSeconds * 1000 * (1 + STALE_FACTOR),
202
322
  deps: deps ?? new Set(),
203
- });
323
+ storedAt: now,
324
+ sharedEncodings: 0,
325
+ };
204
326
 
205
- if (deps) {
206
- for (const dep of deps) {
207
- let set = dependents.get(dep);
208
- if (!set) dependents.set(dep, (set = new Set()));
209
- set.add(key);
210
- }
327
+ install(key, entry);
328
+ share(key, entry);
329
+ }
330
+
331
+ /**
332
+ * Girdiyi L1'e yerleştirir, ters indekse bağlar ve sınırı uygular. Store'a
333
+ * yazmanın tek yolu bu.
334
+ *
335
+ * @param {string} key
336
+ * @param {HtmlEntry} entry
337
+ */
338
+ function install(key, entry) {
339
+ // Aynı anahtarın eski girdisi ters indekste kalmasın: bağımlılıklar
340
+ // tazelemeden tazelemeye değişebilir.
341
+ drop(key);
342
+
343
+ store.set(key, entry);
344
+
345
+ for (const dep of entry.deps) {
346
+ let set = dependents.get(dep);
347
+ if (!set) dependents.set(dep, (set = new Set()));
348
+ set.add(key);
211
349
  }
212
350
 
213
351
  const limit = maxEntries();
@@ -249,36 +387,59 @@ function refresh(key, ttlSeconds, producer) {
249
387
 
250
388
  const token = {};
251
389
  tokens.set(key, token);
390
+
391
+ const task = produce(key, ttlSeconds, producer, token).finally(() => {
392
+ inflight.delete(key);
393
+ if (tokens.get(key) === token) tokens.delete(key);
394
+ });
395
+
396
+ inflight.set(key, task);
397
+ return task;
398
+ }
399
+
400
+ /**
401
+ * @param {string} key
402
+ * @param {number} ttlSeconds
403
+ * @param {() => Promise<{ html: string, status: number, degraded?: boolean,
404
+ * storable?: boolean }>} producer
405
+ * @param {object} token
406
+ * @returns {Promise<{ html: string, status: number, degraded?: boolean,
407
+ * storable?: boolean }>}
408
+ */
409
+ async function produce(key, ttlSeconds, producer, token) {
252
410
  const startedAt = Date.now();
253
411
 
412
+ // Başka bir node bu sayfayı zaten render ettiyse render hiç çalışmaz. Soğuk
413
+ // ayağa kalkan bir instance'ın sıcak önbellek bulmasının tek yolu bu.
414
+ const shared = await readShared(key);
415
+ if (shared && tokens.get(key) === token) {
416
+ install(key, shared);
417
+ return { html: shared.html, status: shared.status };
418
+ }
419
+
254
420
  // Bağımlılıklar tazelemede de toplanır, ilk üretimde değil sadece: sayfanın
255
421
  // okuduğu anahtarlar zamanla değişir (yeni bir widget, kaldırılan bir blok).
256
422
  const deps = trackDependencies() ? new Set() : null;
257
423
 
258
- const task = (deps ? collectDependencies(deps, producer) : producer())
259
- .then((value) => {
260
- // `degraded`: upstream düştüğü için eksik veriyle üretilmiş HTML.
261
- // Saklanırsa eksik içerik tüm TTL boyunca servis edilir.
262
- //
263
- // `storable: false`: çıktı kullanıcıya bağlı (cookie/Authorization
264
- // okundu). Anahtar yalnızca yol + query olduğu için saklamak, bir
265
- // kullanıcının HTML'ini bir başkasına servis etmek olur.
266
- //
267
- // Token uyuşmuyorsa bu tur, sonucu geçersiz kılan bir invalidation'ın
268
- // öncesinde başlamış demektir; yazmak az önce düşürüleni geri koyardı.
269
- const valid = tokens.get(key) === token && !readsPurgedData(deps, startedAt);
270
- if (valid && value.status === 200 && !value.degraded && value.storable !== false) {
271
- write(key, value, ttlSeconds, deps);
272
- }
273
- return value;
274
- })
275
- .finally(() => {
276
- inflight.delete(key);
277
- if (tokens.get(key) === token) tokens.delete(key);
278
- });
424
+ const value = await (deps ? collectDependencies(deps, producer) : producer());
425
+
426
+ // `degraded`: upstream düştüğü için eksik veriyle üretilmiş HTML.
427
+ // Saklanırsa eksik içerik tüm TTL boyunca servis edilir.
428
+ //
429
+ // `storable: false`: çıktı kullanıcıya bağlı (cookie/Authorization
430
+ // okundu). Anahtar yalnızca yol + query olduğu için saklamak, bir
431
+ // kullanıcının HTML'ini bir başkasına servis etmek olur. Paylaşımlı
432
+ // kademede bunun bedeli daha da ağır — bir kullanıcının HTML'i tüm kümeye
433
+ // dağılırdı — bu yüzden kontrol Redis yazımından önce, `write()` içinde.
434
+ //
435
+ // Token uyuşmuyorsa bu tur, sonucu geçersiz kılan bir invalidation'ın
436
+ // öncesinde başlamış demektir; yazmak az önce düşürüleni geri koyardı.
437
+ const valid = tokens.get(key) === token && !readsPurgedData(deps, startedAt);
438
+ if (valid && value.status === 200 && !value.degraded && value.storable !== false) {
439
+ write(key, value, ttlSeconds, deps);
440
+ }
279
441
 
280
- inflight.set(key, task);
281
- return task;
442
+ return value;
282
443
  }
283
444
 
284
445
  /**
@@ -319,6 +480,17 @@ export async function withHtmlCache(key, ttlSeconds, producer) {
319
480
  * yani gerçekten **geçersiz** — bayatlatmak yetmez.
320
481
  */
321
482
  export function clearHtmlCache() {
483
+ clearLocal();
484
+
485
+ if (redisShares("html")) void redisDropMatching("html");
486
+ publishCacheEvent({ type: "html:clear" });
487
+ }
488
+
489
+ /**
490
+ * Boşaltmanın yerel kısmı. Uzaktan gelen olay bunu çağırır: yeniden yayın
491
+ * yapan bir dinleyici iki node arasında sonsuz mesaj döngüsü üretir.
492
+ */
493
+ function clearLocal() {
322
494
  store.clear();
323
495
  dependents.clear();
324
496
  tokens.clear();
@@ -402,22 +574,51 @@ function invalidateKey(key, hard) {
402
574
  * @returns {number} Etkilenen girdi sayısı (uçuştaki render'lar dahil).
403
575
  */
404
576
  export function invalidateHtmlCache(target, options = {}) {
405
- const matchers = (Array.isArray(target) ? target : [target])
406
- .map(toMatcher)
407
- .filter((matcher) => matcher !== null);
577
+ const targets = Array.isArray(target) ? target : [target];
578
+ const hard = options.hard === true;
579
+ const count = invalidateLocal(targets, hard);
580
+
581
+ // Paylaşımlı kopya yumuşak invalidation'da da **silinir**. Bayatlatmanın
582
+ // Redis karşılığı her anahtar için oku-değiştir-yaz turu demek ve bir
583
+ // webhook binlerce anahtarı birden düşürüyor. Silmenin bedeli, o yolu hiç
584
+ // görmemiş bir node'un bir kez render etmesi; L1'i sıcak olan node'lar eski
585
+ // HTML'i bayat pencerede servis etmeye devam ediyor.
586
+ if (redisShares("html")) {
587
+ const matchers = compileMatchers(targets);
588
+ if (matchers.length) {
589
+ void redisDropMatching("html", (key) =>
590
+ matchers.some((matcher) => matcher(pathOf(key))),
591
+ );
592
+ }
593
+ }
594
+
595
+ // Hedefler yayınlanır, eşleşen anahtarlar değil: hangi yolun nerede sıcak
596
+ // olduğu node'a bağlı, her node deseni kendi store'una uygular.
597
+ publishCacheEvent({
598
+ type: "html:invalidate",
599
+ hard,
600
+ targets: targets.map(serializeTarget).filter((entry) => entry !== null),
601
+ });
408
602
 
603
+ return count;
604
+ }
605
+
606
+ /**
607
+ * @param {(string | RegExp)[]} targets
608
+ * @param {boolean} hard
609
+ * @returns {number}
610
+ */
611
+ function invalidateLocal(targets, hard) {
612
+ const matchers = compileMatchers(targets);
409
613
  if (!matchers.length) return 0;
410
614
 
411
- const hard = options.hard === true;
412
615
  let count = 0;
413
616
 
414
617
  // Uçuştaki render'lar da hedeflenir: henüz yazılmamış bir tur, purge'den
415
618
  // önce okunmuş veriyle önbelleğe girmemeli. Anahtarlar kopyalanır, çünkü
416
619
  // `invalidateKey` sert modda store'dan siliyor.
417
620
  for (const key of new Set([...store.keys(), ...tokens.keys()])) {
418
- const mark = key.indexOf("?");
419
- const pathname = mark === -1 ? key : key.slice(0, mark);
420
- if (!matchers.some((matcher) => matcher(pathname))) continue;
621
+ if (!matchers.some((matcher) => matcher(pathOf(key)))) continue;
421
622
  invalidateKey(key, hard);
422
623
  count += 1;
423
624
  }
@@ -425,10 +626,86 @@ export function invalidateHtmlCache(target, options = {}) {
425
626
  return count;
426
627
  }
427
628
 
629
+ /**
630
+ * @param {(string | RegExp)[]} targets
631
+ * @returns {((pathname: string) => boolean)[]}
632
+ */
633
+ function compileMatchers(targets) {
634
+ return /** @type {((pathname: string) => boolean)[]} */ (
635
+ targets.map(toMatcher).filter((matcher) => matcher !== null)
636
+ );
637
+ }
638
+
639
+ /**
640
+ * Anahtar `yol?query`; eşleştirme **yol kısmına** yapılır.
641
+ *
642
+ * @param {string} key
643
+ * @returns {string}
644
+ */
645
+ function pathOf(key) {
646
+ const mark = key.indexOf("?");
647
+ return mark === -1 ? key : key.slice(0, mark);
648
+ }
649
+
650
+ /**
651
+ * `RegExp` JSON'a girmez (`JSON.stringify(/x/)` → `{}`), bu yüzden kaynak ve
652
+ * bayrakları taşınır.
653
+ *
654
+ * @param {string | RegExp} target
655
+ * @returns {string | { re: string, flags: string } | null}
656
+ */
657
+ function serializeTarget(target) {
658
+ if (typeof target === "string") return target;
659
+ if (target instanceof RegExp) return { re: target.source, flags: target.flags };
660
+ return null;
661
+ }
662
+
663
+ /**
664
+ * @param {unknown} value
665
+ * @returns {string | RegExp | null}
666
+ */
667
+ function deserializeTarget(value) {
668
+ if (typeof value === "string") return value;
669
+
670
+ const entry = /** @type {{ re?: unknown, flags?: unknown }} */ (value);
671
+ if (!entry || typeof entry.re !== "string") return null;
672
+
673
+ try {
674
+ return new RegExp(entry.re, typeof entry.flags === "string" ? entry.flags : "");
675
+ } catch {
676
+ // Bozuk bir desen bu node'u düşürmemeli; olay yok sayılır.
677
+ return null;
678
+ }
679
+ }
680
+
681
+ // Uzak bir node invalidation yaptığında bu proses de kendi L1'ini işaretler.
682
+ // Dinleyiciler yalnızca yerel yolları çağırır, yoksa mesaj döngüsü oluşur.
683
+ onCacheEvent((event) => {
684
+ if (event.type === "html:clear") {
685
+ clearLocal();
686
+ return;
687
+ }
688
+
689
+ if (event.type !== "html:invalidate") return;
690
+
691
+ const targets = /** @type {(string | RegExp)[]} */ (
692
+ (Array.isArray(event.targets) ? event.targets : [])
693
+ .map(deserializeTarget)
694
+ .filter((entry) => entry !== null)
695
+ );
696
+
697
+ if (targets.length) invalidateLocal(targets, event.hard === true);
698
+ });
699
+
428
700
  /**
429
701
  * Verilen veri anahtarlarını render sırasında okumuş sayfaları bayatlatır.
430
702
  * `clearDataCache()` bunu çağırır; uygulamanın hiçbir şey bildirmesi gerekmez.
431
703
  *
704
+ * Burada **yayın yapılmaz**: çağıran `clearDataCache()` zaten bir
705
+ * `data:clear` olayı yayınlıyor ve uzak node'lar aynı zinciri kendi ters
706
+ * indeksleri üzerinden çalıştırıyor. Ters indeks node'a özel olduğu için
707
+ * doğru olan da bu — bir sayfa yalnızca onu render etmiş node'da kayıtlı.
708
+ *
432
709
  * @param {Iterable<string>} dataKeys
433
710
  * @returns {number} Etkilenen HTML girdisi sayısı.
434
711
  */
@@ -453,6 +730,15 @@ export function invalidateHtmlByDependency(dataKeys) {
453
730
  }
454
731
 
455
732
  for (const key of keys) invalidateKey(key, false);
733
+
734
+ // Paylaşımlı kopyalar da düşer, yoksa soğuk bir node az önce geçersiz
735
+ // kılınan HTML'i Redis'ten geri alırdı. Yalnızca bu node'un tanıdığı
736
+ // anahtarlar silinebiliyor; hiçbir L1'de sıcak olmayan bir sayfanın Redis
737
+ // kopyası TTL'ini bekler.
738
+ if (keys.size && redisShares("html")) {
739
+ redisDrop([...keys].map((key) => cacheKey("html", key)));
740
+ }
741
+
456
742
  return keys.size;
457
743
  }
458
744
 
@@ -44,6 +44,45 @@ export const prewarmProgress = {
44
44
  entries: [],
45
45
  };
46
46
 
47
+ /**
48
+ * Isıtma turu sırasında bastırılan istek hataları: mesaj → kaç kez görüldü.
49
+ *
50
+ * Yüzlerce yolu tarayan bir tur, upstream bir an için tıksırdığında yüzlerce
51
+ * yığın izini loga döküyor ve asıl bilgi (kaç sayfa ısındı) kayboluyor. Tur
52
+ * boyunca hatalar burada toplanır, tur bitince tek satırda özetlenir.
53
+ * @type {Map<string, number>}
54
+ */
55
+ const suppressed = new Map();
56
+
57
+ /**
58
+ * İstek ısıtma turunun kendi isteği mi? Yalnızca tur çalışırken ve istek
59
+ * ısıtmanın user-agent'ıyla geldiğinde doğru; gerçek trafiğin hataları her
60
+ * zaman loglanmaya devam eder.
61
+ *
62
+ * @param {{ get?: (name: string) => string | undefined }} req
63
+ * @returns {boolean}
64
+ */
65
+ export function isPrewarmRequest(req) {
66
+ if (!prewarmProgress.active) return false;
67
+ const ua = req.get?.("user-agent");
68
+ return Boolean(ua) && ua === getConfig().brand.prewarmUserAgent;
69
+ }
70
+
71
+ /**
72
+ * Bastırılan bir hatayı sayaca ekler. Yığın izi saklanmaz: özet satırının
73
+ * amacı "neyin bozulduğunu" göstermek, hatayı ayıklamak değil — tek bir yol
74
+ * için ayrıntı isteyen dev paneli `entries` üzerinden bakar.
75
+ *
76
+ * @param {number} status
77
+ * @param {unknown} error
78
+ * @returns {void}
79
+ */
80
+ export function notePrewarmError(status, error) {
81
+ const message = error instanceof Error ? error.message : String(error);
82
+ const key = `${status} ${message.split("\n")[0]}`;
83
+ suppressed.set(key, (suppressed.get(key) ?? 0) + 1);
84
+ }
85
+
47
86
  /** @param {unknown} value @param {number} fallback */
48
87
  function num(value, fallback) {
49
88
  const parsed = Number(value);
@@ -326,6 +365,7 @@ export async function prewarm({ origin, quiet = false, paths: only }) {
326
365
  finishedAt: null,
327
366
  entries: [],
328
367
  });
368
+ suppressed.clear();
329
369
 
330
370
  let ok = 0;
331
371
  let failed = 0;
@@ -388,6 +428,22 @@ export async function prewarm({ origin, quiet = false, paths: only }) {
388
428
  `${skipped > 0 ? `, ${skipped} ${rotate ? "deferred to the next pass" : "over the limit"}` : ""}` +
389
429
  ` (${(elapsed / 1000).toFixed(1)}s)`,
390
430
  );
431
+
432
+ // Tur boyunca bastırılan hatalar: en sık görülenler önce, liste uzarsa
433
+ // kalanı tek satırda toplanır. Amaç, logu şişirmeden "ne bozuldu"yu
434
+ // görünür tutmak.
435
+ if (suppressed.size) {
436
+ const ranked = [...suppressed.entries()].sort((a, b) => b[1] - a[1]);
437
+ const shown = ranked.slice(0, 5);
438
+ const rest = ranked.slice(shown.length).reduce((sum, [, n]) => sum + n, 0);
439
+ const total = ranked.reduce((sum, [, n]) => sum + n, 0);
440
+
441
+ console.warn(
442
+ `[prewarm] ${total} request error${total === 1 ? "" : "s"} were not logged individually:\n` +
443
+ shown.map(([message, n]) => ` ${n}× ${message}`).join("\n") +
444
+ (rest ? `\n … ${rest} more in ${ranked.length - shown.length} other kinds` : ""),
445
+ );
446
+ }
391
447
  }
392
448
 
393
449
  return { ok, failed, total: paths.length, elapsed };