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.
- package/CHANGELOG.md +44 -0
- package/docs/06-cache.md +132 -1
- package/docs/07-yapilandirma.md +37 -3
- package/docs/10-dagitim.md +1 -1
- package/docs/en/06-caching.md +138 -1
- package/docs/en/07-configuration.md +37 -3
- package/docs/en/10-deployment.md +1 -1
- package/package.json +5 -1
- package/src/build/paths.mjs +26 -1
- package/src/config/defaults.js +35 -0
- package/src/config/index.js +47 -1
- package/src/index.js +3 -0
- package/src/server/assets.js +28 -0
- package/src/server/create-app.js +97 -14
- package/src/server/data-cache.js +134 -19
- package/src/server/dev/devtools.js +2 -2
- package/src/server/dev/report.js +4 -0
- package/src/server/dev/socket.js +16 -3
- package/src/server/html-cache.js +329 -43
- package/src/server/prewarm.js +56 -0
- package/src/server/redis.js +461 -0
package/src/server/html-cache.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
191
|
-
|
|
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
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
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
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
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
|
-
|
|
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
|
|
406
|
-
|
|
407
|
-
|
|
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
|
-
|
|
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
|
|
package/src/server/prewarm.js
CHANGED
|
@@ -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 };
|