jskelet 0.1.4 → 0.1.6

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.
@@ -0,0 +1,157 @@
1
+ /**
2
+ * Dev araçlarının canlı kanalı: küçük bir WebSocket sunucusu.
3
+ *
4
+ * Overlay eskiden istatistikleri iki saniyede bir `GET /stats` ile çekiyordu;
5
+ * panel kapalıyken bile her sekme sürekli istek atıyor, dev sunucusunun istek
6
+ * günlüğünü de kirletiyordu. Artık sunucu değişiklik oldukça (istek, hata,
7
+ * ısıtma ilerlemesi, CSS/JS değişimi) tek bağlantı üzerinden itiyor.
8
+ *
9
+ * Bağımlılık eklemiyoruz: yalnızca sunucu→istemci metin çerçevesi yazmak ve
10
+ * istemcinin ping/close çerçevelerini anlamak gerekiyor; bu da RFC 6455'in
11
+ * küçük bir alt kümesi. `permessage-deflate` yok, parça (fragment) yok —
12
+ * gönderdiğimiz paketler için ikisi de gereksiz.
13
+ */
14
+ import { createHash } from "node:crypto";
15
+
16
+ /** RFC 6455'te sabitlenmiş el sıkışma tuzu. */
17
+ const GUID = "258EAFA5-E914-47DA-95CA-5AB0DC85B39A";
18
+
19
+ /** @type {Set<import('node:net').Socket>} */
20
+ const sockets = new Set();
21
+
22
+ /** @param {string} key */
23
+ function accept(key) {
24
+ return createHash("sha1")
25
+ .update(key + GUID)
26
+ .digest("base64");
27
+ }
28
+
29
+ /**
30
+ * Tek parçalı metin çerçevesi. Maskeleme yalnızca istemci→sunucu yönünde
31
+ * zorunlu olduğu için burada yok.
32
+ *
33
+ * @param {string} text
34
+ * @returns {Buffer}
35
+ */
36
+ function frame(text) {
37
+ const payload = Buffer.from(text, "utf8");
38
+ const length = payload.length;
39
+
40
+ /** @type {Buffer} */
41
+ let header;
42
+ if (length < 126) {
43
+ header = Buffer.from([0x81, length]);
44
+ } else if (length < 65536) {
45
+ header = Buffer.alloc(4);
46
+ header[0] = 0x81;
47
+ header[1] = 126;
48
+ header.writeUInt16BE(length, 2);
49
+ } else {
50
+ header = Buffer.alloc(10);
51
+ header[0] = 0x81;
52
+ header[1] = 127;
53
+ header.writeBigUInt64BE(BigInt(length), 2);
54
+ }
55
+
56
+ return Buffer.concat([header, payload]);
57
+ }
58
+
59
+ /**
60
+ * İstemciden gelen çerçeveleri ayıklar. İçerik bizi ilgilendirmiyor; amaç
61
+ * kapanışı ve ping'i doğru yanıtlamak, aksi hâlde tarayıcı bağlantıyı
62
+ * "protokol hatası" sayıp kapatıyor.
63
+ *
64
+ * @param {import('node:net').Socket} socket
65
+ */
66
+ function readFrames(socket) {
67
+ let buffer = Buffer.alloc(0);
68
+
69
+ socket.on("data", (chunk) => {
70
+ buffer = Buffer.concat([buffer, chunk]);
71
+
72
+ while (buffer.length >= 2) {
73
+ const opcode = buffer[0] & 0x0f;
74
+ const masked = (buffer[1] & 0x80) !== 0;
75
+ let length = buffer[1] & 0x7f;
76
+ let offset = 2;
77
+
78
+ if (length === 126) {
79
+ if (buffer.length < offset + 2) return;
80
+ length = buffer.readUInt16BE(offset);
81
+ offset += 2;
82
+ } else if (length === 127) {
83
+ if (buffer.length < offset + 8) return;
84
+ length = Number(buffer.readBigUInt64BE(offset));
85
+ offset += 8;
86
+ }
87
+
88
+ if (masked) offset += 4;
89
+ if (buffer.length < offset + length) return;
90
+
91
+ buffer = buffer.subarray(offset + length);
92
+
93
+ // 0x8 close, 0x9 ping. Veri çerçeveleri yok sayılır: istemcinin bize
94
+ // söyleyeceği bir şey yok, kanal tek yönlü kullanılıyor.
95
+ if (opcode === 0x8) {
96
+ socket.end(Buffer.from([0x88, 0x00]));
97
+ return;
98
+ }
99
+ if (opcode === 0x9) socket.write(Buffer.from([0x8a, 0x00]));
100
+ }
101
+ });
102
+ }
103
+
104
+ /**
105
+ * HTTP upgrade isteğini WebSocket bağlantısına çevirir.
106
+ *
107
+ * @param {import('node:http').IncomingMessage} req
108
+ * @param {import('node:net').Socket} socket
109
+ * @param {(send: (payload: object) => void) => void} onOpen
110
+ * Bağlantı kurulunca çağrılır; ilk paketi göndermek için kullanılır.
111
+ */
112
+ export function upgradeToSocket(req, socket, onOpen) {
113
+ const key = req.headers["sec-websocket-key"];
114
+ if (!key) {
115
+ socket.destroy();
116
+ return;
117
+ }
118
+
119
+ socket.write(
120
+ "HTTP/1.1 101 Switching Protocols\r\n" +
121
+ "Upgrade: websocket\r\n" +
122
+ "Connection: Upgrade\r\n" +
123
+ `Sec-WebSocket-Accept: ${accept(String(key))}\r\n\r\n`,
124
+ );
125
+
126
+ // Küçük paketleri geciktirmenin anlamı yok; panel anlık görünmeli.
127
+ socket.setNoDelay(true);
128
+ // Bağlantı hataları (sekme kapanması, ağ) süreci düşürmemeli.
129
+ socket.on("error", () => socket.destroy());
130
+ socket.on("close", () => sockets.delete(socket));
131
+
132
+ readFrames(socket);
133
+ sockets.add(socket);
134
+
135
+ onOpen((payload) => {
136
+ if (!socket.destroyed) socket.write(frame(JSON.stringify(payload)));
137
+ });
138
+ }
139
+
140
+ /**
141
+ * Bağlı tüm panellere gönderir.
142
+ * @param {object} payload
143
+ */
144
+ export function broadcastSocket(payload) {
145
+ if (!sockets.size) return;
146
+
147
+ const packet = frame(JSON.stringify(payload));
148
+ for (const socket of sockets) {
149
+ if (socket.destroyed) sockets.delete(socket);
150
+ else socket.write(packet);
151
+ }
152
+ }
153
+
154
+ /** @returns {number} açık panel sayısı */
155
+ export function socketCount() {
156
+ return sockets.size;
157
+ }
@@ -6,14 +6,24 @@
6
6
  * render'ı beklemez; buna karşılık HTML'deki veri en fazla `revalidate + bir
7
7
  * tazeleme turu` kadar geride olabilir. Fiyat gibi canlı alanlar istemcide
8
8
  * WebSocket'ten güncellendiği için bu gecikme ekranda görünmez.
9
+ *
10
+ * TTL'in yanında ikinci bir tazelik kaynağı daha var: **hedefli
11
+ * invalidation**. Bir içerik güncellendiğinde tüm önbelleği boşaltmak
12
+ * (`clearHtmlCache()`) o an sıcak olan her sayfayı soğuk render'a çevirir;
13
+ * TTL'i beklemek ise güncellemeyi dakikalarca geciktirir.
14
+ * `invalidateHtmlCache()` ikisinin arasını açar ve varsayılan davranışı
15
+ * **bayatlatmaktır**: girdi silinmez, süresi geçmiş sayılır. Ziyaretçi eski
16
+ * HTML'i beklemeden alır, tazeleme arkada tek seferde koşar.
9
17
  */
10
18
 
11
19
  import { getConfig } from "../config/index.js";
12
20
  import { DEFAULT_HTML_CACHE_MAX_ENTRIES } from "../config/defaults.js";
21
+ import { collectDependencies } from "./cache-deps.js";
22
+ import { compilePattern, matchPattern } from "../config/pattern.js";
13
23
 
14
24
  /**
15
25
  * @typedef {{ html: string, status: number, expiresAt: number,
16
- * staleUntil: number, encoded: Map<string, Buffer> }} HtmlEntry
26
+ * staleUntil: number, encoded: Map<string, Buffer>, deps: Set<string> }} HtmlEntry
17
27
  */
18
28
 
19
29
  /**
@@ -34,6 +44,20 @@ function maxEntries() {
34
44
  }
35
45
  }
36
46
 
47
+ /**
48
+ * Bağımlılık izleme kapatılabilir olmalı: `withDataCache` kullanmayan bir
49
+ * uygulamada hiçbir şey kaydedilmez ama bağlam kurma maliyeti kalır.
50
+ *
51
+ * @returns {boolean}
52
+ */
53
+ function trackDependencies() {
54
+ try {
55
+ return getConfig().trackDependencies;
56
+ } catch {
57
+ return true;
58
+ }
59
+ }
60
+
37
61
  /**
38
62
  * TTL dolduktan sonra eski HTML'in kaç TTL boyunca daha servis edilebileceği.
39
63
  * Tazeleme genelde ilk stale istekte tamamlandığı için bu pencere yalnızca
@@ -47,6 +71,86 @@ const store = new Map();
47
71
  /** @type {Map<string, Promise<{ html: string, status: number }>>} */
48
72
  const inflight = new Map();
49
73
 
74
+ /**
75
+ * Uçuştaki her tazelemenin kimliği. Bir girdi tazelenirken invalidate
76
+ * edilirse o tazelemenin sonucu **artık geçersizdir**: render, purge'den önce
77
+ * okunmuş veriyle üretildi. Token silinince `write()` atlanır ve bir sonraki
78
+ * istek yeni bir tur başlatır.
79
+ *
80
+ * @type {Map<string, object>}
81
+ */
82
+ const tokens = new Map();
83
+
84
+ /**
85
+ * Ters indeks: veri anahtarı → onu okumuş HTML anahtarları. `clearDataCache()`
86
+ * bunu okuyup etkilenen sayfaları bayatlatır.
87
+ *
88
+ * @type {Map<string, Set<string>>}
89
+ */
90
+ const dependents = new Map();
91
+
92
+ /**
93
+ * Invalidate edilmiş ama henüz kimsenin istemediği yollar. Isıtma turu bunları
94
+ * kuyruğun başına alır: "içerik güncellendi" bilgisi geldiğinde sayfa,
95
+ * ziyaretçi gelmesini beklemeden tazelenir.
96
+ *
97
+ * Sınırlı tutulur — kimse ısıtma yapmıyorsa bu küme sessizce büyümemeli.
98
+ *
99
+ * @type {Set<string>}
100
+ */
101
+ const invalidated = new Set();
102
+
103
+ const MAX_INVALIDATED = 500;
104
+
105
+ /**
106
+ * Son zamanlarda düşürülen veri anahtarları ve düşürülme zamanları.
107
+ *
108
+ * Bir webhook, sayfa **render edilirken** gelirse ters indeks henüz o sayfayı
109
+ * tanımıyor (bağımlılıklar yazma anında kaydediliyor) ve render, purge'den
110
+ * önce okunmuş veriyle önbelleğe girerdi. Yazma anında bu haritaya bakmak,
111
+ * "doğduğu anda bayat" girdiyi engeller.
112
+ *
113
+ * Render'lar saniyeler sürdüğü için harita kısa tutulur; sınır aşılınca en
114
+ * eski kayıt düşer.
115
+ *
116
+ * @type {Map<string, number>}
117
+ */
118
+ const purgedDeps = new Map();
119
+
120
+ const MAX_PURGED_DEPS = 1000;
121
+
122
+ /**
123
+ * Girdiyi ters indeksten söker. Bu adım atlanırsa indeks, düşen girdilerin
124
+ * anahtarlarını tutmaya devam eder ve sessizce sızar.
125
+ *
126
+ * @param {string} key
127
+ * @param {HtmlEntry} entry
128
+ */
129
+ function unlink(key, entry) {
130
+ for (const dep of entry.deps) {
131
+ const set = dependents.get(dep);
132
+ if (!set) continue;
133
+ set.delete(key);
134
+ if (!set.size) dependents.delete(dep);
135
+ }
136
+ }
137
+
138
+ /**
139
+ * Store'dan silmenin **tek** yolu. Ters indeks bakımı buraya bağlı olduğu için
140
+ * hiçbir yerde doğrudan `store.delete()` çağrılmaz.
141
+ *
142
+ * @param {string} key
143
+ * @returns {boolean} Girdi var mıydı.
144
+ */
145
+ function drop(key) {
146
+ const entry = store.get(key);
147
+ if (!entry) return false;
148
+
149
+ unlink(key, entry);
150
+ store.delete(key);
151
+ return true;
152
+ }
153
+
50
154
  /**
51
155
  * @param {string} key
52
156
  * @returns {{ html: string, status: number, encoded: Map<string, Buffer>,
@@ -58,7 +162,7 @@ function read(key) {
58
162
 
59
163
  const now = Date.now();
60
164
  if (now >= entry.staleUntil) {
61
- store.delete(key);
165
+ drop(key);
62
166
  return null;
63
167
  }
64
168
 
@@ -78,10 +182,15 @@ function read(key) {
78
182
  * @param {string} key
79
183
  * @param {{ html: string, status: number }} value
80
184
  * @param {number} ttlSeconds
185
+ * @param {Set<string> | null} deps Render sırasında okunan veri anahtarları.
81
186
  */
82
- function write(key, value, ttlSeconds) {
187
+ function write(key, value, ttlSeconds, deps = null) {
83
188
  const now = Date.now();
84
189
 
190
+ // Aynı anahtarın eski girdisi ters indekste kalmasın: bağımlılıklar
191
+ // tazelemeden tazelemeye değişebilir.
192
+ drop(key);
193
+
85
194
  store.set(key, {
86
195
  html: value.html,
87
196
  status: value.status,
@@ -90,14 +199,40 @@ function write(key, value, ttlSeconds) {
90
199
  encoded: new Map(),
91
200
  expiresAt: now + ttlSeconds * 1000,
92
201
  staleUntil: now + ttlSeconds * 1000 * (1 + STALE_FACTOR),
202
+ deps: deps ?? new Set(),
93
203
  });
94
204
 
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
+ }
211
+ }
212
+
95
213
  const limit = maxEntries();
96
214
  while (store.size > limit) {
97
215
  const oldest = store.keys().next().value;
98
216
  if (oldest === undefined) break;
99
- store.delete(oldest);
217
+ drop(oldest);
218
+ }
219
+ }
220
+
221
+ /**
222
+ * Bu render, başladıktan sonra düşürülmüş bir veriyi mi okudu.
223
+ *
224
+ * @param {Set<string> | null} deps
225
+ * @param {number} startedAt
226
+ * @returns {boolean}
227
+ */
228
+ function readsPurgedData(deps, startedAt) {
229
+ if (!deps) return false;
230
+
231
+ for (const dep of deps) {
232
+ const purgedAt = purgedDeps.get(dep);
233
+ if (purgedAt !== undefined && purgedAt >= startedAt) return true;
100
234
  }
235
+ return false;
101
236
  }
102
237
 
103
238
  /**
@@ -112,7 +247,15 @@ function refresh(key, ttlSeconds, producer) {
112
247
  const pending = inflight.get(key);
113
248
  if (pending) return pending;
114
249
 
115
- const task = producer()
250
+ const token = {};
251
+ tokens.set(key, token);
252
+ const startedAt = Date.now();
253
+
254
+ // Bağımlılıklar tazelemede de toplanır, ilk üretimde değil sadece: sayfanın
255
+ // okuduğu anahtarlar zamanla değişir (yeni bir widget, kaldırılan bir blok).
256
+ const deps = trackDependencies() ? new Set() : null;
257
+
258
+ const task = (deps ? collectDependencies(deps, producer) : producer())
116
259
  .then((value) => {
117
260
  // `degraded`: upstream düştüğü için eksik veriyle üretilmiş HTML.
118
261
  // Saklanırsa eksik içerik tüm TTL boyunca servis edilir.
@@ -120,13 +263,18 @@ function refresh(key, ttlSeconds, producer) {
120
263
  // `storable: false`: çıktı kullanıcıya bağlı (cookie/Authorization
121
264
  // okundu). Anahtar yalnızca yol + query olduğu için saklamak, bir
122
265
  // kullanıcının HTML'ini bir başkasına servis etmek olur.
123
- if (value.status === 200 && !value.degraded && value.storable !== false) {
124
- write(key, value, ttlSeconds);
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);
125
272
  }
126
273
  return value;
127
274
  })
128
275
  .finally(() => {
129
276
  inflight.delete(key);
277
+ if (tokens.get(key) === token) tokens.delete(key);
130
278
  });
131
279
 
132
280
  inflight.set(key, task);
@@ -152,6 +300,7 @@ export async function withHtmlCache(key, ttlSeconds, producer) {
152
300
  // Süresi geçmiş girdi anında döner; tazeleme arkada yürür ve hatası
153
301
  // isteği etkilemez (eski HTML stale penceresi boyunca geçerli kalır).
154
302
  if (hit.stale) {
303
+ invalidated.delete(key);
155
304
  void refresh(key, ttlSeconds, producer).catch((error) => {
156
305
  console.error(`[html-cache] background refresh failed: ${key}`, error);
157
306
  });
@@ -159,24 +308,177 @@ export async function withHtmlCache(key, ttlSeconds, producer) {
159
308
  return { ...hit, cached: true };
160
309
  }
161
310
 
311
+ invalidated.delete(key);
162
312
  const value = await refresh(key, ttlSeconds, producer);
163
313
  return { ...value, encoded: store.get(key)?.encoded, cached: false };
164
314
  }
165
315
 
316
+ /**
317
+ * Store'u tamamen boşaltır. Dev sunucusu manifest her değiştiğinde bunu
318
+ * çağırır: saklanan HTML artık var olmayan hash'li varlıkları işaret ediyor,
319
+ * yani gerçekten **geçersiz** — bayatlatmak yetmez.
320
+ */
166
321
  export function clearHtmlCache() {
167
322
  store.clear();
323
+ dependents.clear();
324
+ tokens.clear();
325
+ invalidated.clear();
326
+ purgedDeps.clear();
168
327
  }
169
328
 
170
329
  export function getHtmlCacheSize() {
171
330
  return store.size;
172
331
  }
173
332
 
333
+ /**
334
+ * Verilen hedefi HTML anahtarının yol kısmıyla eşleştiren bir eşleyici üretir.
335
+ *
336
+ * Üç biçim kabul edilir:
337
+ * `"/haber/abc"` → o yol ve altındaki her şey (`/haber/abc/yorumlar`)
338
+ * `"/haber/:slug"` → config'in her yerinde geçerli desen sözdizimi
339
+ * `/-yorumlar$/` → desen sözdiziminin karşılamadığı kurallar için
340
+ *
341
+ * Düz string'te "önek" bilinçli olarak **segment sınırında** kesilir: `/haber`
342
+ * kuralı `/haberler`i düşürmemeli.
343
+ *
344
+ * @param {string | RegExp} target
345
+ * @returns {((pathname: string) => boolean) | null}
346
+ */
347
+ function toMatcher(target) {
348
+ if (target instanceof RegExp) return (pathname) => target.test(pathname);
349
+
350
+ if (typeof target !== "string" || !target.startsWith("/")) {
351
+ console.warn(`[html-cache] invalid invalidation target (must start with \`/\`): ${target}`);
352
+ return null;
353
+ }
354
+
355
+ if (target.includes(":")) {
356
+ const compiled = compilePattern(target);
357
+ if (!compiled) return null;
358
+ return (pathname) => matchPattern(compiled, pathname) !== null;
359
+ }
360
+
361
+ const prefix = target.endsWith("/") ? target : `${target}/`;
362
+ return (pathname) => pathname === target || pathname.startsWith(prefix);
363
+ }
364
+
365
+ /**
366
+ * Etkilenen girdiyi bayatlatır ya da düşürür.
367
+ *
368
+ * @param {string} key
369
+ * @param {boolean} hard
370
+ */
371
+ function invalidateKey(key, hard) {
372
+ // Uçuştaki tazeleme bu invalidation'dan önce başladıysa sonucu eski veriyle
373
+ // üretilmiş demektir; token'ı düşürmek onu yazılamaz hâle getirir. Girdi
374
+ // henüz hiç yazılmamış olsa bile (ilk render sürüyor) bu geçerli.
375
+ tokens.delete(key);
376
+
377
+ const entry = store.get(key);
378
+ if (entry) {
379
+ // Bayat penceresi de dolmuşsa girdi zaten ölü: bayatlatmanın etkisi olmaz.
380
+ if (hard || Date.now() >= entry.staleUntil) drop(key);
381
+ else entry.expiresAt = 0;
382
+ }
383
+
384
+ if (invalidated.size < MAX_INVALIDATED) invalidated.add(key);
385
+ }
386
+
387
+ /**
388
+ * Hedefli invalidation: TTL'i beklemeden, ama tüm önbelleği boşaltmadan.
389
+ *
390
+ * Varsayılan **yumuşaktır** (`hard: false`): girdi silinmez, süresi geçmiş
391
+ * sayılır. Bir webhook beş yüz sayfayı birden düşürdüğünde sert silme, tam da
392
+ * içeriğin güncellendiği anda beş yüz soğuk render başlatır ve upstream'i
393
+ * döver. Bayatlatmada ise ziyaretçi eski HTML'i beklemeden alır, tazeleme
394
+ * arkada ve anahtar başına tek seferde koşar. `hard: true` yalnızca eski
395
+ * HTML'in gerçekten geçersiz olduğu durumlar için.
396
+ *
397
+ * Anahtar `yol?query` olduğundan eşleştirme **yol kısmına** yapılır: bir
398
+ * yolun bütün query varyantları (`?utm_source=…` dahil) tek çağrıyla düşer.
399
+ *
400
+ * @param {string | RegExp | (string | RegExp)[]} target
401
+ * @param {{ hard?: boolean }} [options]
402
+ * @returns {number} Etkilenen girdi sayısı (uçuştaki render'lar dahil).
403
+ */
404
+ export function invalidateHtmlCache(target, options = {}) {
405
+ const matchers = (Array.isArray(target) ? target : [target])
406
+ .map(toMatcher)
407
+ .filter((matcher) => matcher !== null);
408
+
409
+ if (!matchers.length) return 0;
410
+
411
+ const hard = options.hard === true;
412
+ let count = 0;
413
+
414
+ // Uçuştaki render'lar da hedeflenir: henüz yazılmamış bir tur, purge'den
415
+ // önce okunmuş veriyle önbelleğe girmemeli. Anahtarlar kopyalanır, çünkü
416
+ // `invalidateKey` sert modda store'dan siliyor.
417
+ 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;
421
+ invalidateKey(key, hard);
422
+ count += 1;
423
+ }
424
+
425
+ return count;
426
+ }
427
+
428
+ /**
429
+ * Verilen veri anahtarlarını render sırasında okumuş sayfaları bayatlatır.
430
+ * `clearDataCache()` bunu çağırır; uygulamanın hiçbir şey bildirmesi gerekmez.
431
+ *
432
+ * @param {Iterable<string>} dataKeys
433
+ * @returns {number} Etkilenen HTML girdisi sayısı.
434
+ */
435
+ export function invalidateHtmlByDependency(dataKeys) {
436
+ /** @type {Set<string>} */
437
+ const keys = new Set();
438
+ const now = Date.now();
439
+
440
+ for (const dep of dataKeys) {
441
+ // Şu anda render edilen bir sayfa bu veriyi okuduysa ters indekste henüz
442
+ // görünmüyor; yazma anındaki kontrol için zaman damgası bırakılır.
443
+ purgedDeps.set(dep, now);
444
+
445
+ const set = dependents.get(dep);
446
+ if (set) for (const key of set) keys.add(key);
447
+ }
448
+
449
+ while (purgedDeps.size > MAX_PURGED_DEPS) {
450
+ const oldest = purgedDeps.keys().next().value;
451
+ if (oldest === undefined) break;
452
+ purgedDeps.delete(oldest);
453
+ }
454
+
455
+ for (const key of keys) invalidateKey(key, false);
456
+ return keys.size;
457
+ }
458
+
459
+ /**
460
+ * Invalidate edilmiş ve henüz kimsenin istemediği yolları döner ve kuyruğu
461
+ * boşaltır. Isıtma turu bunları başa alır; iki tur aynı yolu tekrar
462
+ * ısıtmasın diye okuma yıkıcıdır.
463
+ *
464
+ * @returns {string[]}
465
+ */
466
+ export function takeInvalidatedPaths() {
467
+ if (!invalidated.size) return [];
468
+
469
+ const paths = [...invalidated];
470
+ invalidated.clear();
471
+ // Anahtar `yol?query`; query boşsa sondaki `?` atılır.
472
+ return paths.map((key) => (key.endsWith("?") ? key.slice(0, -1) : key));
473
+ }
474
+
174
475
  /**
175
476
  * Dev raporu için önbellek dökümü: hangi sayfa ne kadar HTML tutuyor, ne
176
- * zaman bayatlıyor. HTML gövdesi dönmez, yalnızca boyutu.
477
+ * zaman bayatlıyor, kaç veri anahtarına bağlı. HTML gövdesi dönmez, yalnızca
478
+ * boyutu.
177
479
  *
178
480
  * @returns {{ key: string, bytes: number, status: number, stale: boolean,
179
- * expiresIn: number, encodings: string[] }[]}
481
+ * expiresIn: number, encodings: string[], deps: number }[]}
180
482
  */
181
483
  export function getHtmlCacheEntries() {
182
484
  const now = Date.now();
@@ -188,5 +490,6 @@ export function getHtmlCacheEntries() {
188
490
  stale: now >= entry.expiresAt,
189
491
  expiresIn: Math.round((entry.expiresAt - now) / 1000),
190
492
  encodings: [...entry.encoded.keys()],
493
+ deps: entry.deps.size,
191
494
  }));
192
495
  }
@@ -20,6 +20,7 @@
20
20
  */
21
21
  import process from "node:process";
22
22
  import { getConfig, hook } from "../config/index.js";
23
+ import { takeInvalidatedPaths } from "./html-cache.js";
23
24
 
24
25
  /**
25
26
  * Isıtmanın canlı durumu. Dev araçları bunu okuyup ilerlemeyi gösterir;
@@ -286,24 +287,35 @@ async function crawl(origin, paths, concurrency, report = undefined, pace = unde
286
287
  export async function prewarm({ origin, quiet = false, paths: only }) {
287
288
  const started = Date.now();
288
289
  const limit = setting("PREWARM_MAX", "max", 400);
289
- // Dev'de daha az paralellik: tarama, o an tarayıcıda açtığın sayfanın
290
- // render'ıyla CPU için yarışmasın.
291
- const concurrency = setting(
292
- "PREWARM_CONCURRENCY",
293
- "concurrency",
294
- process.env.NODE_ENV === "development" ? 2 : 4,
295
- );
290
+ const isDev = process.env.NODE_ENV === "development";
296
291
 
297
- const rps = num(process.env.PREWARM_RPS, num(getConfig().prewarm?.rps, 0));
292
+ // Dev'de tek işçi: tarama, o an tarayıcıda açtığın sayfanın render'ıyla CPU
293
+ // için yarışmasın.
294
+ const concurrency = setting("PREWARM_CONCURRENCY", "concurrency", isDev ? 1 : 4);
295
+
296
+ // Render tek bir olay döngüsünde çalışıyor: aralıksız bir tur, geliştirme
297
+ // sırasında sayfa isteklerini ve dev panelinin kanalını arkasında bekletiyor.
298
+ // Dev'de varsayılan bir hız freni bu yüzden var; üretimde ısıtma bir kez
299
+ // olup bittiği için fren yalnızca istenirse (`prewarm.rps`) devreye girer.
300
+ const rps = num(process.env.PREWARM_RPS, num(getConfig().prewarm?.rps, isDev ? 4 : 0));
298
301
  const pace = createPacer(rps);
299
302
 
300
303
  const all = only?.length ? only : await collectPaths();
301
304
  // Elle verilen liste budanmaz ve sıralanmaz: çağıran tam olarak neyi
302
305
  // istediğini biliyor (dev panelindeki "tekrar dene" bunu kullanır).
303
- const paths = only?.length
306
+ const selected = only?.length
304
307
  ? all
305
308
  : selectPrewarmPaths(all, limit, getConfig().prewarm?.rotate !== false);
306
309
 
310
+ // Invalidate edilmiş sayfalar kuyruğun önüne geçer: "içerik güncellendi"
311
+ // bilgisi geldiğinde sayfa, ziyaretçi gelmesini beklemeden tazelenir. Bu
312
+ // yollar `max` bütçesinin dışında tutulur — sayıları zaten gerçekleşen
313
+ // invalidation kadar ve rotasyonun sırasını bozmaları istenmez.
314
+ const pending = only?.length ? [] : takeInvalidatedPaths();
315
+ const paths = pending.length
316
+ ? [...new Set([...pending, ...selected])]
317
+ : selected;
318
+
307
319
  Object.assign(prewarmProgress, {
308
320
  active: true,
309
321
  done: 0,
@@ -363,13 +375,14 @@ export async function prewarm({ origin, quiet = false, paths: only }) {
363
375
  const elapsed = Date.now() - started;
364
376
 
365
377
  if (!quiet && paths.length) {
366
- const skipped = all.length - paths.length;
378
+ const skipped = all.length - selected.length;
367
379
  // Rotasyon açıkken sınırın dışında kalan yollar kaybolmuyor, bir sonraki
368
380
  // tura kalıyor; log bunu ayırt etmeli, yoksa "400 yol atlandı" satırı
369
381
  // hatalı bir kurulum sanılıyor.
370
382
  const rotate = !only?.length && getConfig().prewarm?.rotate !== false;
371
383
  console.log(
372
384
  `[prewarm] warmed ${ok}/${paths.length} pages` +
385
+ `${pending.length ? `, ${pending.length} invalidated` : ""}` +
373
386
  `${failed ? `, ${failed} failed` : ""}` +
374
387
  `${recovered ? `, ${recovered} recovered on the retry pass` : ""}` +
375
388
  `${skipped > 0 ? `, ${skipped} ${rotate ? "deferred to the next pass" : "over the limit"}` : ""}` +