jskelet 0.1.6 → 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.
@@ -397,6 +397,15 @@ let restarts = 0;
397
397
  * yenileme yapılır; overlay durumu sekme belleğinde durduğu için panel açık
398
398
  * kalmaya devam eder.
399
399
  */
400
+ /**
401
+ * Hiç açılmadan üst üste bu kadar denemeden sonra kanal yerine yedek yola
402
+ * geçilir. Tek bir başarısızlık yetmiyor: sayfa, sunucunun yeniden başlama
403
+ * penceresinde açılmış olabilir ve o an soket kurulamaz.
404
+ */
405
+ const SOCKET_ATTEMPTS = 4;
406
+
407
+ let socketFailures = 0;
408
+
400
409
  function connectSocket() {
401
410
  let socket;
402
411
  try {
@@ -408,12 +417,11 @@ function connectSocket() {
408
417
  return;
409
418
  }
410
419
 
411
- // Soket hiç açılamazsa (proxy WebSocket'i geçirmiyor olabilir) eski
412
- // SSE + yoklama yoluna düşülür; dev akışı bir ara katman yüzünden körelmesin.
413
420
  let opened = false;
414
421
 
415
422
  socket.addEventListener("open", () => {
416
423
  opened = true;
424
+ socketFailures = 0;
417
425
  });
418
426
 
419
427
  socket.addEventListener("message", (event) => {
@@ -421,18 +429,31 @@ function connectSocket() {
421
429
  });
422
430
 
423
431
  socket.addEventListener("close", () => {
424
- if (!opened) {
425
- startFallback();
426
- return;
427
- }
428
-
429
- // Sunucu yeniden başlıyor: gösterge "bağlantı yok"a döner ve kısa aralıkla
432
+ // Sunucu yeniden başlıyor olabilir: gösterge "bağlantı yok"a döner ve
430
433
  // yeniden denenir. Açılışta gelen `hello` yeniden başlatmayı bildirir.
431
434
  if (!offline) {
432
435
  offline = true;
433
436
  render();
434
437
  }
435
- setTimeout(connectSocket, 500);
438
+
439
+ if (opened) {
440
+ socketFailures = 0;
441
+ setTimeout(connectSocket, 500);
442
+ return;
443
+ }
444
+
445
+ socketFailures += 1;
446
+
447
+ // Kanal hiç kurulamıyorsa araya giren bir katman WebSocket'i geçirmiyor
448
+ // demektir; dev akışı bunun yüzünden körelmesin diye eski yola düşülür.
449
+ if (socketFailures >= SOCKET_ATTEMPTS) {
450
+ startFallback();
451
+ return;
452
+ }
453
+
454
+ // Yeniden başlatma birkaç saniye sürebiliyor; aralık kademeli açılır ki
455
+ // tarayıcı konsolu başarısız bağlantı satırlarıyla dolmasın.
456
+ setTimeout(connectSocket, socketFailures * 700);
436
457
  });
437
458
  }
438
459
 
@@ -120,6 +120,41 @@ export const DEFAULT_DATA_CACHE = {
120
120
  staleFactor: 10,
121
121
  };
122
122
 
123
+ /**
124
+ * Opsiyonel Redis ikinci kademesi (L2).
125
+ *
126
+ * Redis **birincil store değil**: bellek içi önbellek (L1) aynen kalır, Redis
127
+ * iki iş yapar — L1'de bulunmayan bir sayfa için render'ı atlatmak ve
128
+ * invalidation'ı bütün node'lara yaymak. Tek instance çalışan bir kurulumda
129
+ * kazanç neredeyse yok; bu yüzden `enabled` varsayılan olarak kapalı.
130
+ *
131
+ * `storeEncoded` kapalı, çünkü sıkıştırılmış gövdeleri de paylaşmak girdi
132
+ * başına boyutu iki-üç katına çıkarır ve brotli'yi yeniden üretmek Redis'ten
133
+ * indirmekten çoğu zaman daha ucuz.
134
+ */
135
+ export const DEFAULT_REDIS = {
136
+ enabled: false,
137
+ /** `redis://` ya da `rediss://`. Boşsa ioredis varsayılanı (localhost:6379). */
138
+ url: /** @type {string | null} */ (null),
139
+ /** Aynı Redis'i paylaşan birden fazla uygulamayı ayırır. */
140
+ namespace: "default",
141
+ keyPrefix: "_jskelet",
142
+ /** HTML gövdeleri paylaşılsın mı. */
143
+ html: true,
144
+ /** Veri önbelleği paylaşılsın mı. */
145
+ data: true,
146
+ /** Brotli/gzip gövdeleri de paylaşılsın mı. */
147
+ storeEncoded: false,
148
+ /** pub/sub üzerinden invalidation yayını. */
149
+ events: true,
150
+ /**
151
+ * Tek bir komutun en fazla bekletebileceği süre. Önbellek okuması isteği
152
+ * bloklayan bir adım: Redis takıldığında render'a düşmek, ağı beklemekten
153
+ * iyidir.
154
+ */
155
+ commandTimeoutMs: 200,
156
+ };
157
+
123
158
  /** Oturuma bağlı sayfalar ısıtılmaz; uygulama kendi listesini verebilir. */
124
159
  export const DEFAULT_PREWARM_SKIP = ["/api/", "/_fragment/", "/__jskelet/"];
125
160
 
@@ -16,7 +16,7 @@
16
16
  * redirects() → [{ source, destination, permanent?, statusCode? }]
17
17
  * rewrites() → [{ source, destination }] | { beforeFiles?, afterFiles? }
18
18
  * cache() → { html?: { [source]: saniye }, maxEntries?: number,
19
- * data?: {...}, prewarm?: {...} }
19
+ * data?: {...}, redis?: {...}, prewarm?: {...} }
20
20
  *
21
21
  * Fonksiyon olmayan bölümler (`brand`, `security`, `static`, `navigation`…)
22
22
  * düz nesne olarak okunur.
@@ -36,6 +36,7 @@ import {
36
36
  DEFAULT_NAVIGATION_EXCLUDE,
37
37
  DEFAULT_PREWARM,
38
38
  DEFAULT_PREWARM_SKIP,
39
+ DEFAULT_REDIS,
39
40
  DEFAULT_SECURITY,
40
41
  DEFAULT_STATIC,
41
42
  DEFAULT_TRANSIENT_RETRY,
@@ -56,6 +57,19 @@ const CONFIG_FILE = "jskelet.config.mjs";
56
57
  * @property {string[]} exclude Spekülasyon dışı bırakılan href desenleri.
57
58
  */
58
59
 
60
+ /**
61
+ * @typedef {object} RedisConfig
62
+ * @property {boolean} enabled
63
+ * @property {string | null} url
64
+ * @property {string} namespace
65
+ * @property {string} keyPrefix
66
+ * @property {boolean} html HTML gövdeleri paylaşılsın mı.
67
+ * @property {boolean} data Veri önbelleği paylaşılsın mı.
68
+ * @property {boolean} storeEncoded Sıkıştırılmış gövdeler de paylaşılsın mı.
69
+ * @property {boolean} events pub/sub invalidation yayını.
70
+ * @property {number} commandTimeoutMs
71
+ */
72
+
59
73
  /**
60
74
  * @typedef {import('./pattern.js').CompiledPattern} CompiledPattern
61
75
  *
@@ -72,6 +86,7 @@ const CONFIG_FILE = "jskelet.config.mjs";
72
86
  * @property {boolean} trackUpstream `fetch` sarılıp geçici hatalar otomatik bildirilsin mi.
73
87
  * @property {boolean} trackDependencies Render'ın okuduğu veri anahtarları kaydedilsin mi.
74
88
  * @property {{ attempts: number, delayMs: number }} transientRetry
89
+ * @property {RedisConfig} redis Opsiyonel Redis ikinci kademesi.
75
90
  * @property {Record<string, unknown>} prewarm
76
91
  * @property {{ source: string, test: (pathname: string) => boolean }[]} prewarmPriority
77
92
  * @property {Record<string, unknown>} brand
@@ -207,12 +222,40 @@ function normalizePriority(raw) {
207
222
  return out;
208
223
  }
209
224
 
225
+ /**
226
+ * Redis bölümü. Bozuk bir değer sunucuyu düşürmemeli: her alan tipine
227
+ * zorlanır ve `enabled` yalnızca açıkça `true` verildiğinde açılır.
228
+ *
229
+ * @param {unknown} raw
230
+ * @returns {RedisConfig}
231
+ */
232
+ function normalizeRedis(raw) {
233
+ const source = /** @type {Record<string, any>} */ (raw ?? {});
234
+ const timeout = Number(source.commandTimeoutMs);
235
+
236
+ return {
237
+ enabled: source.enabled === true,
238
+ url: typeof source.url === "string" && source.url ? source.url : null,
239
+ namespace: String(source.namespace ?? DEFAULT_REDIS.namespace),
240
+ keyPrefix: String(source.keyPrefix ?? DEFAULT_REDIS.keyPrefix),
241
+ html: source.html !== false,
242
+ data: source.data !== false,
243
+ storeEncoded: source.storeEncoded === true,
244
+ events: source.events !== false,
245
+ commandTimeoutMs:
246
+ Number.isFinite(timeout) && timeout > 0
247
+ ? Math.floor(timeout)
248
+ : DEFAULT_REDIS.commandTimeoutMs,
249
+ };
250
+ }
251
+
210
252
  /**
211
253
  * @param {unknown} raw
212
254
  * @returns {{ html: ResolvedConfig["html"], htmlMaxEntries: number,
213
255
  * data: Record<string, unknown>, trackUpstream: boolean,
214
256
  * trackDependencies: boolean,
215
257
  * transientRetry: { attempts: number, delayMs: number },
258
+ * redis: RedisConfig,
216
259
  * prewarm: Record<string, unknown>,
217
260
  * prewarmPriority: ResolvedConfig["prewarmPriority"] }}
218
261
  */
@@ -248,6 +291,7 @@ function normalizeCache(raw) {
248
291
  raw?.transientRetry === false
249
292
  ? { attempts: 0, delayMs: 0 }
250
293
  : { ...DEFAULT_TRANSIENT_RETRY, ...(raw?.transientRetry ?? {}) },
294
+ redis: normalizeRedis(raw?.redis),
251
295
  // Desenler derlenmiş hâlde ayrı alanda tutulur: `prewarm` sayısal
252
296
  // ayarların düz torbası olarak kalsın, her turda yeniden derlenmesin.
253
297
  prewarm,
@@ -465,6 +509,7 @@ export async function loadConfig(options = {}) {
465
509
  trackUpstream,
466
510
  trackDependencies,
467
511
  transientRetry,
512
+ redis,
468
513
  prewarm,
469
514
  prewarmPriority,
470
515
  } = normalizeCache(cache);
@@ -484,6 +529,7 @@ export async function loadConfig(options = {}) {
484
529
  trackUpstream,
485
530
  trackDependencies,
486
531
  transientRetry,
532
+ redis,
487
533
  prewarm,
488
534
  prewarmPriority,
489
535
  brand,
package/src/index.js CHANGED
@@ -53,6 +53,9 @@ export {
53
53
  getDataCacheSize,
54
54
  withDataCache,
55
55
  } from "./server/data-cache.js";
56
+ // Paylaşımlı önbellek kademesinin durumu. Bağlantı kurulmamışken de güvenle
57
+ // çağrılır; healthcheck uçları bunu okuyor.
58
+ export { getRedisStatus } from "./server/redis.js";
56
59
  export { prewarm, prewarmProgress } from "./server/prewarm.js";
57
60
  export { createProxy } from "./server/middleware/upstream-proxy.js";
58
61
  export { getConfig, loadConfig } from "./config/index.js";
@@ -60,6 +60,34 @@ export function hasAsset(name) {
60
60
  return Boolean(state().manifest[name]);
61
61
  }
62
62
 
63
+ /** @type {string | null} */
64
+ let buildId = null;
65
+
66
+ /**
67
+ * Bu build'i tanımlayan kısa kimlik (`build.json` → `id`).
68
+ *
69
+ * Paylaşımlı önbellek anahtarlarının isim alanı bu: saklanan HTML hash'li
70
+ * varlık yollarını gömdüğü için bir deploy'dan sonra eski HTML **geçersizdir**.
71
+ *
72
+ * Build çalışmadıysa `"dev"` döner — build çıktısı olmadan da ayağa kalkma
73
+ * kuralı burada da geçerli.
74
+ *
75
+ * @returns {string}
76
+ */
77
+ export function getBuildId() {
78
+ if (!isDev && buildId) return buildId;
79
+
80
+ try {
81
+ const file = path.join(getConfig().dirs.generated, "build.json");
82
+ const id = JSON.parse(fs.readFileSync(file, "utf8")).id;
83
+ buildId = typeof id === "string" && id ? id : "dev";
84
+ } catch {
85
+ buildId = "dev";
86
+ }
87
+
88
+ return buildId;
89
+ }
90
+
63
91
  /**
64
92
  * @typedef {{ width: number, height: number,
65
93
  * variants: { width: number, url: string }[] }} OptimizedImage
@@ -33,8 +33,9 @@ import { IMMUTABLE_CACHE } from "../config/defaults.js";
33
33
  import { registerRoutes } from "./router.js";
34
34
  import { renderNotFound } from "./render.js";
35
35
  import { renderStatusPage, statusFromError } from "./status-page.js";
36
- import { startPrewarm } from "./prewarm.js";
36
+ import { isPrewarmRequest, notePrewarmError, startPrewarm } from "./prewarm.js";
37
37
  import { trackUpstreamFetch } from "./upstream-tracking.js";
38
+ import { connectRedis, disconnectRedis } from "./redis.js";
38
39
  import { isNotFoundError, isRedirectError } from "../http/control-flow.js";
39
40
 
40
41
  /**
@@ -51,6 +52,12 @@ export async function createApp(options = {}) {
51
52
  // ilk kurulduğu istek de kapsanmalı.
52
53
  if (config.trackUpstream) trackUpstreamFetch();
53
54
 
55
+ // Önbelleğin ikinci kademesi route'lardan önce kurulmalı: ilk istek de
56
+ // paylaşımlı kopyayı görebilsin. Bağlanamazsa uyarı basılır ve uygulama
57
+ // bellek içi önbellekle çalışmaya devam eder — middleware sırasına
58
+ // dokunmayan, tamamen opsiyonel bir adım.
59
+ await connectRedis(config);
60
+
54
61
  const app = express();
55
62
 
56
63
  app.disable("x-powered-by");
@@ -132,7 +139,11 @@ export async function createApp(options = {}) {
132
139
  }
133
140
 
134
141
  const status = statusFromError(error);
135
- console.error(`[${status}] ${req.method} ${req.originalUrl}`, error);
142
+ // Isıtma turunun hataları tek tek loglanmaz; tur bitince özet olarak
143
+ // basılır. Yüzlerce yolu tarayan bir tur, upstream bir an tıksırdığında
144
+ // logu yığın izleriyle dolduruyordu.
145
+ if (isPrewarmRequest(req)) notePrewarmError(status, error);
146
+ else console.error(`[${status}] ${req.method} ${req.originalUrl}`, error);
136
147
 
137
148
  // Hata sayfası hiçbir katmanda saklanmamalı: geçici bir upstream arızası
138
149
  // CDN'de dakikalarca yaşayan bir 500 sayfasına dönüşmesin.
@@ -156,7 +167,14 @@ export async function createApp(options = {}) {
156
167
  export async function startServer(options = {}) {
157
168
  const app = await createApp(options);
158
169
  const port = Number(options.port ?? process.env.PORT ?? 3000);
159
- const host = options.host ?? process.env.HOST ?? "0.0.0.0";
170
+
171
+ // Varsayılan `::`, `0.0.0.0` değil: ikisi de "tüm arayüzler" demek, ama
172
+ // yalnızca IPv6 soketi çift yığın çalışır ve `localhost`un `::1`e çözüldüğü
173
+ // durumu da kapsar. Tarayıcılar `localhost` için önce `::1` deniyor; sıradan
174
+ // isteklerde IPv4'e düşüyorlar ama WebSocket el sıkışması bu geri düşüşü
175
+ // yapmadan "failed" veriyordu. IPv6'sı olmayan bir makinede bağlama hata
176
+ // verir; aşağıda IPv4'e dönülür.
177
+ const host = options.host ?? process.env.HOST ?? null;
160
178
 
161
179
  // Tek bir istek hatası süreci düşürmesin; logla ve ayakta kal. Bir haber
162
180
  // sitesinde tek sayfanın hatası tüm siteyi indirmemeli.
@@ -176,17 +194,82 @@ export async function startServer(options = {}) {
176
194
  ? (await import("./dev/devtools.js")).attachDevSocket
177
195
  : null;
178
196
 
179
- return new Promise((resolve) => {
180
- const server = app.listen(port, host, () => {
181
- // Bu satırın biçimi sözleşme: `jskelet dev` sunucunun hazır olduğunu
182
- // buradan anlar ve özet satırını ona göre basar.
183
- console.log(
184
- `jskelet → http://localhost:${port} (${process.env.NODE_ENV ?? "production"})`,
185
- );
186
- startPrewarm({ port });
187
- resolve(server);
188
- });
197
+ return new Promise((resolve, reject) => {
198
+ /** @param {string} address */
199
+ const listen = (address) => {
200
+ const server = app.listen(port, address, () => {
201
+ // Bu satırın biçimi sözleşme: `jskelet dev` sunucunun hazır olduğunu
202
+ // buradan anlar ve özet satırını ona göre basar.
203
+ console.log(
204
+ `jskelet → http://localhost:${port} (${process.env.NODE_ENV ?? "production"})`,
205
+ );
206
+ startPrewarm({ port });
207
+ attachShutdown(server);
208
+ resolve(server);
209
+ });
210
+
211
+ server.on("error", (error) => {
212
+ // IPv6 desteklenmiyorsa yalnızca varsayılan adres için IPv4'e dönülür;
213
+ // kullanıcı bir adres verdiyse sessizce başkasını dinlemek yanlış olur.
214
+ if (!host && isAddressUnsupported(error)) {
215
+ listen("0.0.0.0");
216
+ return;
217
+ }
218
+ reject(error);
219
+ });
220
+
221
+ attachDevSocket?.(server);
222
+ };
189
223
 
190
- attachDevSocket?.(server);
224
+ listen(host ?? "::");
191
225
  });
192
226
  }
227
+
228
+ /**
229
+ * `SIGTERM`/`SIGINT` sonrası düzenli kapanış.
230
+ *
231
+ * Kapatılması gereken tek dış bağlantı Redis ve `quit` uçuştaki komutların
232
+ * bitmesini bekliyor; sert `disconnect` yarıda kalan bir `SET` bırakabiliyor.
233
+ *
234
+ * Açık HTTP bağlantıları **beklenmez**. `close()` tek başına yalnızca yeni
235
+ * bağlantıyı reddediyor; keep-alive bir istemci ya da dev panelinin
236
+ * WebSocket'i sunucuyu süresiz ayakta tutuyor ve Ctrl+C yanıt vermiyormuş gibi
237
+ * görünüyordu. Sinyal geldiğinde ters proxy zaten trafik göndermiyor.
238
+ *
239
+ * Yine de bir zamanlayıcı var: Redis kapanışı askıda kalırsa süreç `SIGKILL`
240
+ * beklemek zorunda kalmasın.
241
+ *
242
+ * @param {import('http').Server} server
243
+ */
244
+ function attachShutdown(server) {
245
+ let closing = false;
246
+
247
+ const shutdown = () => {
248
+ // İkinci sinyal beklemeyi kısa kessin: kullanıcı Ctrl+C'ye tekrar bastıysa
249
+ // gerçekten çıkmak istiyor.
250
+ if (closing) process.exit(0);
251
+ closing = true;
252
+
253
+ const timer = setTimeout(() => process.exit(0), 3000);
254
+ timer.unref();
255
+
256
+ server.close();
257
+ server.closeAllConnections?.();
258
+
259
+ void disconnectRedis().finally(() => {
260
+ clearTimeout(timer);
261
+ process.exit(0);
262
+ });
263
+ };
264
+
265
+ process.once("SIGTERM", shutdown);
266
+ process.once("SIGINT", shutdown);
267
+ }
268
+
269
+ /**
270
+ * @param {NodeJS.ErrnoException} error
271
+ * @returns {boolean} adres ailesi bu makinede kullanılamıyor mu
272
+ */
273
+ function isAddressUnsupported(error) {
274
+ return error.code === "EAFNOSUPPORT" || error.code === "EADDRNOTAVAIL";
275
+ }
@@ -16,11 +16,26 @@
16
16
  * genellikle `null` döner ve bunu saklamak, geçici bir 429'u TTL boyunca "veri
17
17
  * yok" hâline dondurmak olurdu. Boş cevabı bilinçli olarak saklamak isteyen
18
18
  * `storeEmpty: true` verir.
19
+ *
20
+ * `cache.redis` açıkken bu önbellek ikinci bir kademeye (L2) yaslanır. Redis'e
21
+ * en uygun katman burası: JSON küçük, sıkıştırılmış varyant sorunu yok ve
22
+ * kazanç doğrudan API kotasına yazılıyor — bir node'un çektiği veri hepsine
23
+ * yeter. Redis kapalı ya da erişilemez olduğunda bu modül birebir eskisi gibi
24
+ * çalışır.
19
25
  */
20
26
  import { getConfig } from "../config/index.js";
21
27
  import { DEFAULT_DATA_CACHE } from "../config/defaults.js";
22
28
  import { recordDependency } from "./cache-deps.js";
23
29
  import { invalidateHtmlByDependency } from "./html-cache.js";
30
+ import {
31
+ cacheKey,
32
+ onCacheEvent,
33
+ publishCacheEvent,
34
+ redisDropMatching,
35
+ redisGetJson,
36
+ redisSetJson,
37
+ redisShares,
38
+ } from "./redis.js";
24
39
 
25
40
  /**
26
41
  * @typedef {{ value: unknown, expiresAt: number, staleUntil: number }} DataEntry
@@ -74,6 +89,16 @@ function read(key) {
74
89
  return { value: entry.value, stale: now >= entry.expiresAt };
75
90
  }
76
91
 
92
+ /** Girdi sınırını aşan en eski kayıtları düşürür. */
93
+ function evict() {
94
+ const { maxEntries } = settings();
95
+ while (store.size > maxEntries) {
96
+ const oldest = store.keys().next().value;
97
+ if (oldest === undefined) break;
98
+ store.delete(oldest);
99
+ }
100
+ }
101
+
77
102
  /**
78
103
  * @param {string} key
79
104
  * @param {unknown} value
@@ -84,18 +109,55 @@ function write(key, value, ttlSeconds, staleFactor) {
84
109
  const now = Date.now();
85
110
  const ttl = ttlSeconds * 1000;
86
111
 
87
- store.set(key, {
112
+ /** @type {DataEntry} */
113
+ const entry = {
88
114
  value,
89
115
  expiresAt: now + ttl,
90
116
  staleUntil: now + ttl + ttl * staleFactor,
91
- });
117
+ };
92
118
 
93
- const { maxEntries } = settings();
94
- while (store.size > maxEntries) {
95
- const oldest = store.keys().next().value;
96
- if (oldest === undefined) break;
97
- store.delete(oldest);
119
+ store.set(key, entry);
120
+
121
+ // Redis kopyası ateşle-unut: çağıran taraf beklemez. Anahtarın Redis ömrü
122
+ // bayat penceresinin sonuna kadar, çünkü bayat veri de işe yarıyor.
123
+ if (redisShares("data")) {
124
+ redisSetJson(cacheKey("data", key), entry, entry.staleUntil - now);
98
125
  }
126
+
127
+ evict();
128
+ }
129
+
130
+ /**
131
+ * Başka bir node'un yazdığı girdiyi L1'e alır. TTL yeniden başlatılmaz:
132
+ * mutlak zamanlar olduğu gibi korunur, yoksa girdi node'dan node'a atlayarak
133
+ * süresiz tazelik kazanır.
134
+ *
135
+ * @param {string} key
136
+ * @param {DataEntry} entry
137
+ */
138
+ function promote(key, entry) {
139
+ store.set(key, entry);
140
+ evict();
141
+ }
142
+
143
+ /**
144
+ * Paylaşımlı kademeden okur.
145
+ *
146
+ * Yalnızca **taze** girdi kabul edilir. Bayat bir kopyayı L1'e almak
147
+ * tazelemeyi sonsuza kadar ertelerdi: girdi bayat kalır, her tazeleme turu
148
+ * yine Redis'i okur ve `producer` hiç çalışmaz.
149
+ *
150
+ * @param {string} key
151
+ * @returns {Promise<DataEntry | null>}
152
+ */
153
+ async function readShared(key) {
154
+ if (!redisShares("data")) return null;
155
+
156
+ const entry = await redisGetJson(cacheKey("data", key));
157
+ if (!entry || typeof entry.expiresAt !== "number") return null;
158
+ if (Date.now() >= entry.expiresAt) return null;
159
+
160
+ return /** @type {DataEntry} */ (entry);
99
161
  }
100
162
 
101
163
  /**
@@ -113,23 +175,41 @@ function refresh(key, ttlSeconds, producer, options) {
113
175
 
114
176
  const staleFactor = options.staleFactor ?? settings().staleFactor;
115
177
 
116
- const task = Promise.resolve()
117
- .then(producer)
118
- .then((value) => {
119
- const empty = value === undefined || value === null;
120
- if (!empty || options.storeEmpty === true) {
121
- write(key, value, ttlSeconds, staleFactor);
122
- }
123
- return value;
124
- })
125
- .finally(() => {
126
- inflight.delete(key);
127
- });
178
+ const task = produce(key, ttlSeconds, producer, options, staleFactor).finally(() => {
179
+ inflight.delete(key);
180
+ });
128
181
 
129
182
  inflight.set(key, task);
130
183
  return task;
131
184
  }
132
185
 
186
+ /**
187
+ * @param {string} key
188
+ * @param {number} ttlSeconds
189
+ * @param {() => Promise<unknown>} producer
190
+ * @param {{ storeEmpty?: boolean, staleFactor?: number }} options
191
+ * @param {number} staleFactor
192
+ * @returns {Promise<unknown>}
193
+ */
194
+ async function produce(key, ttlSeconds, producer, options, staleFactor) {
195
+ // Başka bir node bu anahtarı çoktan tazelediyse upstream'e hiç gitmeyiz.
196
+ // Kotayı koruyan `inflight` birleştirmesinin küme çapındaki karşılığı bu.
197
+ const shared = await readShared(key);
198
+ if (shared) {
199
+ promote(key, shared);
200
+ return shared.value;
201
+ }
202
+
203
+ const value = await producer();
204
+
205
+ const empty = value === undefined || value === null;
206
+ if (!empty || options.storeEmpty === true) {
207
+ write(key, value, ttlSeconds, staleFactor);
208
+ }
209
+
210
+ return value;
211
+ }
212
+
133
213
  /**
134
214
  * Veriyi önbellekten döner, gerekiyorsa `producer` ile üretir.
135
215
  *
@@ -212,10 +292,38 @@ export function dataCache(fn, options) {
212
292
  * uygulamanın ayrıca `invalidateHtmlCache()` çağırması gerekmez ve aynı veriyi
213
293
  * gösteren liste sayfalarını unutmak mümkün değildir (bkz. `cache-deps.js`).
214
294
  *
295
+ * `cache.redis` açıkken çağrı ayrıca paylaşımlı kademeden siler ve diğer
296
+ * node'lara duyurulur — bugün bir webhook yalnızca isteği alan node'un
297
+ * önbelleğini tazeliyor, diğerleri TTL'i bekliyordu.
298
+ *
215
299
  * @param {string} [prefix] Verilmezse tüm önbellek boşaltılır.
216
300
  * @returns {number} Silinen girdi sayısı.
217
301
  */
218
302
  export function clearDataCache(prefix) {
303
+ const removed = clearLocal(prefix);
304
+
305
+ if (redisShares("data")) {
306
+ void redisDropMatching(
307
+ "data",
308
+ prefix === undefined ? undefined : (key) => key.startsWith(prefix),
309
+ );
310
+ }
311
+
312
+ // Yayın yerel silmeden **sonra** yapılır; diğer node'lar kendi anahtarlarını
313
+ // kendileri tarar, çünkü hangi anahtarın nerede sıcak olduğu node'a bağlı.
314
+ publishCacheEvent({ type: "data:clear", prefix: prefix ?? null });
315
+
316
+ return removed;
317
+ }
318
+
319
+ /**
320
+ * Silmenin yerel kısmı. Uzaktan gelen olay bunu çağırır: yeniden yayın yapan
321
+ * bir dinleyici iki node arasında sonsuz mesaj döngüsü üretir.
322
+ *
323
+ * @param {string} [prefix]
324
+ * @returns {number}
325
+ */
326
+ function clearLocal(prefix) {
219
327
  /** @type {string[]} */
220
328
  const removed = [];
221
329
 
@@ -235,6 +343,13 @@ export function clearDataCache(prefix) {
235
343
  return removed.length;
236
344
  }
237
345
 
346
+ // Uzak bir node veri düşürdüğünde bu proses de kendi L1'ini temizler; zincir
347
+ // `invalidateHtmlByDependency` üzerinden etkilenen sayfalara kadar gider.
348
+ onCacheEvent((event) => {
349
+ if (event.type !== "data:clear") return;
350
+ clearLocal(typeof event.prefix === "string" ? event.prefix : undefined);
351
+ });
352
+
238
353
  /** @returns {number} */
239
354
  export function getDataCacheSize() {
240
355
  return store.size;
@@ -461,11 +461,11 @@ export function mountDevtools(app) {
461
461
  export function attachDevSocket(server) {
462
462
  const endpoint = `${getConfig().brand.devBasePath}/ws`;
463
463
 
464
- server.on("upgrade", (req, socket) => {
464
+ server.on("upgrade", (req, socket, head) => {
465
465
  // Uygulamanın kendi WebSocket uçları olabilir; yalnızca bizimkini alırız.
466
466
  if ((req.url ?? "").split("?")[0] !== endpoint) return;
467
467
 
468
- upgradeToSocket(req, socket, (send) => {
468
+ upgradeToSocket(req, socket, head, (send) => {
469
469
  send({ type: "hello", boot: BOOT_ID });
470
470
  send(statsPayload());
471
471
  });
@@ -14,6 +14,7 @@ import path from "node:path";
14
14
  import zlib from "node:zlib";
15
15
  import { getHtmlCacheEntries, getHtmlCacheSize } from "../html-cache.js";
16
16
  import { getDataCacheSize } from "../data-cache.js";
17
+ import { getRedisStatus } from "../redis.js";
17
18
  import { prewarmProgress } from "../prewarm.js";
18
19
  import { getConfig } from "../../config/index.js";
19
20
 
@@ -350,6 +351,9 @@ export function buildReport(devtools) {
350
351
  // Veri önbelleğinden yalnızca sayaç: uzun kuyruklu bir sitede on
351
352
  // binlerce anahtar oluyor ve dökümü rapora koymak faydasız bir yük.
352
353
  data: getDataCacheSize(),
354
+ // Paylaşımlı kademe kapalıyken de basılır: "Redis'i açtım ama neden
355
+ // çalışmıyor" sorusunun cevabı en çok burada aranıyor.
356
+ redis: getRedisStatus(),
353
357
  },
354
358
  prewarm: { ...prewarmProgress },
355
359
  requests: devtools.requests,