jskelet 0.6.3 → 0.6.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (153) hide show
  1. package/AGENTS.md +136 -136
  2. package/CHANGELOG.md +628 -620
  3. package/LICENSE +21 -21
  4. package/README.md +2 -0
  5. package/bin/jskelet.mjs +130 -130
  6. package/docs/01-baslangic.md +291 -291
  7. package/docs/02-mimari.md +310 -310
  8. package/docs/03-routing.md +515 -515
  9. package/docs/04-render-ve-sablonlar.md +667 -661
  10. package/docs/05-islands.md +486 -486
  11. package/docs/06-cache.md +1467 -1443
  12. package/docs/07-yapilandirma.md +1208 -1197
  13. package/docs/08-build.md +429 -429
  14. package/docs/09-dev-araclari.md +364 -364
  15. package/docs/10-dagitim.md +348 -338
  16. package/docs/12-panel-ve-oturum.md +479 -478
  17. package/docs/README.md +83 -83
  18. package/docs/en/01-getting-started.md +298 -298
  19. package/docs/en/02-architecture.md +329 -329
  20. package/docs/en/03-routing.md +531 -531
  21. package/docs/en/04-rendering.md +675 -669
  22. package/docs/en/05-islands.md +497 -497
  23. package/docs/en/06-caching.md +1476 -1453
  24. package/docs/en/07-configuration.md +1229 -1219
  25. package/docs/en/08-build.md +447 -447
  26. package/docs/en/09-dev-tools.md +373 -373
  27. package/docs/en/10-deployment.md +351 -340
  28. package/docs/en/11-migration.md +398 -398
  29. package/docs/en/12-dashboards-and-sessions.md +489 -488
  30. package/docs/en/README.md +87 -87
  31. package/package.json +137 -137
  32. package/src/build/ensure-build.mjs +19 -19
  33. package/src/build/paths.mjs +153 -153
  34. package/src/build/resolve-peer.mjs +36 -36
  35. package/src/build/tasks/client.mjs +349 -349
  36. package/src/build/tasks/css.mjs +235 -235
  37. package/src/build/tasks/fonts.mjs +146 -146
  38. package/src/build/tasks/icons.mjs +357 -357
  39. package/src/build/tasks/images.mjs +244 -244
  40. package/src/build/tasks/precompress.mjs +78 -78
  41. package/src/build/tasks/templates.mjs +20 -20
  42. package/src/client/admin/i18n.js +764 -764
  43. package/src/client/admin/login.html +74 -74
  44. package/src/client/admin/panel.css +809 -809
  45. package/src/client/admin/panel.html +495 -495
  46. package/src/client/admin/panel.js +1251 -1251
  47. package/src/client/devtools/report.html +185 -185
  48. package/src/client/devtools/report.js +745 -745
  49. package/src/client/devtools/seo.js +628 -628
  50. package/src/client/dom.js +95 -95
  51. package/src/client/form.js +192 -192
  52. package/src/client/index.js +45 -45
  53. package/src/client/registry.js +305 -305
  54. package/src/client/safe-image.js +91 -91
  55. package/src/client/shared-cookie.js +225 -225
  56. package/src/client/store.js +36 -36
  57. package/src/client/swap.js +188 -188
  58. package/src/compile/codegen.js +336 -336
  59. package/src/compile/compile-all.js +149 -149
  60. package/src/compile/errors.js +66 -66
  61. package/src/compile/expr.js +409 -409
  62. package/src/compile/index.js +17 -17
  63. package/src/compile/parse.js +541 -541
  64. package/src/compile/resolve.js +211 -211
  65. package/src/compile/scan-exports.js +51 -51
  66. package/src/config/defaults.js +541 -534
  67. package/src/config/index.js +1500 -1469
  68. package/src/config/pattern.js +107 -107
  69. package/src/generate.mjs +163 -163
  70. package/src/http/control-flow.js +71 -71
  71. package/src/http/cookies-entry.js +21 -21
  72. package/src/http/cookies.js +277 -277
  73. package/src/http/request-cache.js +46 -46
  74. package/src/http/request-context.js +165 -165
  75. package/src/http/shared-cookie.js +178 -178
  76. package/src/index.js +101 -101
  77. package/src/init.mjs +232 -230
  78. package/src/migrate/apply.mjs +262 -262
  79. package/src/migrate/babel.mjs +79 -79
  80. package/src/migrate/classify.mjs +155 -155
  81. package/src/migrate/config.mjs +126 -126
  82. package/src/migrate/fs-walk.mjs +191 -191
  83. package/src/migrate/parse.mjs +26 -26
  84. package/src/migrate/scan.mjs +177 -177
  85. package/src/migrate/transform/expr-source.mjs +168 -168
  86. package/src/migrate/transform/island.mjs +67 -67
  87. package/src/migrate/transform/jsx-to-component.mjs +302 -302
  88. package/src/migrate/transform/jsx-to-jsk.mjs +330 -330
  89. package/src/migrate/transform/page-split.mjs +435 -435
  90. package/src/migrate/write.mjs +81 -81
  91. package/src/migrate.mjs +171 -171
  92. package/src/runtime/alias-hooks.mjs +119 -119
  93. package/src/runtime/register.mjs +4 -4
  94. package/src/server/admin/actions.js +229 -229
  95. package/src/server/admin/auth.js +125 -125
  96. package/src/server/admin/event-log.js +151 -151
  97. package/src/server/admin/gate.js +209 -209
  98. package/src/server/admin/inventory.js +188 -188
  99. package/src/server/admin/mount.js +56 -56
  100. package/src/server/admin/router.js +216 -216
  101. package/src/server/admin/snapshot.js +241 -241
  102. package/src/server/assets.js +147 -147
  103. package/src/server/auth/handoff.js +309 -309
  104. package/src/server/cache-blob.js +70 -70
  105. package/src/server/cache-control.js +45 -0
  106. package/src/server/cache-deps.js +42 -42
  107. package/src/server/cache-vary.js +113 -113
  108. package/src/server/cloudflare.js +607 -607
  109. package/src/server/create-app.js +366 -366
  110. package/src/server/data-cache.js +553 -553
  111. package/src/server/dev/report.js +485 -485
  112. package/src/server/dev/socket.js +170 -170
  113. package/src/server/dev/version-check.mjs +139 -139
  114. package/src/server/disk-cache.js +233 -233
  115. package/src/server/ejs-adapter.js +59 -59
  116. package/src/server/html-cache.js +1196 -1196
  117. package/src/server/image-optimizer.js +500 -500
  118. package/src/server/logs/access-middleware.js +66 -66
  119. package/src/server/logs/file-sink.js +193 -193
  120. package/src/server/logs/pipeline.js +165 -165
  121. package/src/server/logs/s3-put.js +214 -214
  122. package/src/server/logs/s3-sink.js +112 -112
  123. package/src/server/metadata.js +102 -102
  124. package/src/server/middleware/compression.js +205 -205
  125. package/src/server/middleware/csrf.js +134 -134
  126. package/src/server/middleware/dev-gate.js +75 -75
  127. package/src/server/middleware/headers.js +37 -37
  128. package/src/server/middleware/redirects.js +32 -32
  129. package/src/server/middleware/robots-txt.js +341 -341
  130. package/src/server/middleware/static-precompressed.js +121 -121
  131. package/src/server/middleware/trailing-slash.js +53 -53
  132. package/src/server/middleware/upstream-proxy.js +141 -141
  133. package/src/server/og-image.js +369 -356
  134. package/src/server/port-guard.js +255 -255
  135. package/src/server/prewarm.js +1082 -1082
  136. package/src/server/redis.js +588 -588
  137. package/src/server/render.js +910 -910
  138. package/src/server/router.js +157 -157
  139. package/src/server/status-page.js +265 -265
  140. package/src/server/upstream-limiter.js +376 -376
  141. package/src/server/upstream-tracking.js +166 -166
  142. package/src/shared/cookie-domain.js +66 -66
  143. package/src/start.mjs +22 -22
  144. package/src/templates/layout.ejs +30 -30
  145. package/src/templates/layout.jsk +30 -30
  146. package/src/version.mjs +31 -31
  147. package/src/views/components/loader.js +101 -101
  148. package/src/views/helpers/html.js +102 -102
  149. package/src/views/helpers/tags.js +375 -375
  150. package/types/config/defaults.d.ts +6 -0
  151. package/types/config/index.d.ts +6 -0
  152. package/types/server/cache-control.d.ts +28 -0
  153. package/types/server/og-image.d.ts +5 -0
@@ -1,588 +1,588 @@
1
- /**
2
- * Opsiyonel Redis ikinci kademesi (L2) ve invalidation yayını.
3
- *
4
- * Redis **birincil store olmaz**. `html-cache.js` bellekten senkron okuyor,
5
- * sıkıştırılmış gövdeleri girdiyle birlikte tutuyor ve "bu render purge'den
6
- * önce mi başladı" sorusunu tek proseste cevaplıyor. Bu makineyi ağın arkasına
7
- * almak hem her isteğe gecikme ekler hem de yarışları küme çapında hâle
8
- * getirir. Bunun yerine bellek içi önbellek (L1) aynen kalır ve Redis iki iş
9
- * yapar:
10
- *
11
- * 1. **L1 miss'te render'ı atlatmak.** Yeni ayağa kalkan ya da o yolu hiç
12
- * görmemiş bir node, başka bir node'un ürettiği HTML'i bulur.
13
- * 2. **Invalidation'ı yaymak.** Bugün bir webhook yalnızca isteği alan
14
- * node'un önbelleğini tazeliyor; diğerleri TTL'i bekliyor. Asıl kazanç
15
- * depolama değil, bu.
16
- *
17
- * Sözleşme: Redis yoksa, bağlanamıyorsa ya da bir komut hata verirse
18
- * **Redis'siz davranış birebir korunur**. Bu modüldeki hiçbir fonksiyon
19
- * fırlatmaz; okuma `null`, yazma sessiz no-op'a döner.
20
- */
21
- import crypto from "node:crypto";
22
- import { DEFAULT_REDIS } from "../config/defaults.js";
23
- import { tryImportFromApp } from "../build/resolve-peer.mjs";
24
- import { getBuildId } from "./assets.js";
25
- import { decodeCacheValue, encodeCacheValue } from "./cache-blob.js";
26
-
27
- /**
28
- * @typedef {import('../config/index.js').RedisConfig} RedisConfig
29
- *
30
- * @typedef {{ type: string, [key: string]: unknown }} CacheEvent
31
- */
32
-
33
- /** @type {any} */
34
- let client = null;
35
- /** @type {any} */
36
- let subscriber = null;
37
-
38
- /** @type {RedisConfig} */
39
- let settings = { ...DEFAULT_REDIS };
40
-
41
- /** Anahtar öneki `_jskelet:{ns}:{buildId}` — bir kez hesaplanır. */
42
- let prefix = "";
43
- let channel = "";
44
-
45
- /**
46
- * Bu prosesin kimliği. Kendi yayınladığı mesajı işlemek, yerel invalidation'ı
47
- * ikinci kez çalıştırmak ve `data:clear` gibi zincirleme olaylarda sonsuz
48
- * döngü üretmek olurdu.
49
- */
50
- const originId = crypto.randomUUID();
51
-
52
- /** @type {((event: CacheEvent) => void)[]} */
53
- const listeners = [];
54
-
55
- let errors = 0;
56
-
57
- /**
58
- * Devre kesici. Redis düştüğünde her istek `commandTimeoutMs` beklemesin:
59
- * art arda gelen hatalardan sonra istemci bir süre baypas edilir. Süre
60
- * dolduğunda tek bir komut denenir ve başarılıysa sayaç sıfırlanır.
61
- */
62
- let consecutiveFailures = 0;
63
- let bypassUntil = 0;
64
-
65
- const MAX_FAILURES = 5;
66
- const BYPASS_MS = 5000;
67
-
68
- /** @returns {boolean} */
69
- function usable() {
70
- if (!client) return false;
71
- if (bypassUntil && Date.now() < bypassUntil) return false;
72
- return true;
73
- }
74
-
75
- /**
76
- * @param {string} action
77
- * @param {unknown} error
78
- */
79
- function noteFailure(action, error) {
80
- errors += 1;
81
- consecutiveFailures += 1;
82
-
83
- if (consecutiveFailures >= MAX_FAILURES && !bypassUntil) {
84
- bypassUntil = Date.now() + BYPASS_MS;
85
- console.warn(
86
- `[redis] ${MAX_FAILURES} consecutive failures — bypassing for ${BYPASS_MS}ms`,
87
- );
88
- }
89
-
90
- // Her hata loglanmaz: Redis düştüğünde saniyede yüzlerce satır basardı.
91
- if (consecutiveFailures <= MAX_FAILURES) {
92
- console.warn(
93
- `[redis] ${action} failed`,
94
- error instanceof Error ? error.message : error,
95
- );
96
- }
97
- }
98
-
99
- function noteSuccess() {
100
- consecutiveFailures = 0;
101
- bypassUntil = 0;
102
- }
103
-
104
- /**
105
- * Bağlantıyı kurar. `createApp()` config yüklendikten sonra bir kez çağırır.
106
- *
107
- * `ioredis` opsiyonel peer bağımlılık ve **uygulamanın** node_modules'ünden
108
- * çözülür: framework `file:`/workspace bağlantısıyla kuruluysa düz bir
109
- * `import "ioredis"` framework'ün kendi ağacına bakar.
110
- *
111
- * @param {import('../config/index.js').ResolvedConfig} config
112
- * @returns {Promise<boolean>} Bağlantı kuruldu mu.
113
- */
114
- export async function connectRedis(config) {
115
- const redis = config.redis ?? DEFAULT_REDIS;
116
- if (!redis.enabled) return false;
117
-
118
- const module = await tryImportFromApp(config.root, "ioredis");
119
- if (!module) {
120
- console.warn(
121
- "[redis] cache.redis.enabled is set but `ioredis` is not installed — " +
122
- "falling back to the in-process cache.",
123
- );
124
- return false;
125
- }
126
-
127
- const Redis = module.default ?? module.Redis;
128
- if (typeof Redis !== "function") {
129
- console.warn("[redis] `ioredis` did not export a constructor, ignoring it");
130
- return false;
131
- }
132
-
133
- const options = {
134
- // Bağlantı `createApp()` içinde beklenmez: Redis erişilemezse sunucu yine
135
- // ayağa kalkmalı, sadece L2 devre dışı kalır.
136
- lazyConnect: true,
137
- // Tek bir komut isteği bloklayan adım; kuyrukta birikmesine izin verilmez.
138
- commandTimeout: redis.commandTimeoutMs,
139
- maxRetriesPerRequest: 1,
140
- enableOfflineQueue: false,
141
- };
142
-
143
- try {
144
- client = redis.url ? new Redis(redis.url, options) : new Redis(options);
145
- // Bağlantı hatası bir `error` dinleyicisi olmadan süreci düşürür.
146
- client.on("error", (error) => noteFailure("connection", error));
147
- await client.connect();
148
- } catch (error) {
149
- console.warn(
150
- "[redis] could not connect — falling back to the in-process cache:",
151
- error instanceof Error ? error.message : error,
152
- );
153
- try {
154
- client?.disconnect();
155
- } catch {
156
- // Zaten kopmuş olabilir.
157
- }
158
- client = null;
159
- return false;
160
- }
161
-
162
- settings = redis;
163
- prefix = `${redis.keyPrefix}:${redis.namespace}:${getBuildId()}`;
164
- // Kanal bilinçli olarak `buildId` **içermez**: deploy sırasında eski ve yeni
165
- // sürüm yan yana koşuyor ve purge mesajı ikisine de ulaşmalı.
166
- channel = `${redis.keyPrefix}:${redis.namespace}:events`;
167
-
168
- if (redis.events) await subscribe(Redis, redis, options);
169
-
170
- console.log(`[redis] connected — keys under \`${prefix}:\``);
171
- return true;
172
- }
173
-
174
- /**
175
- * pub/sub için ikinci bir bağlantı: abone olmuş bir istemci normal komut
176
- * kabul etmez.
177
- *
178
- * @param {Function} Redis
179
- * @param {RedisConfig} redis
180
- * @param {Record<string, unknown>} options
181
- */
182
- async function subscribe(Redis, redis, options) {
183
- try {
184
- subscriber =
185
- typeof client.duplicate === "function"
186
- ? client.duplicate()
187
- : redis.url
188
- ? new Redis(redis.url, options)
189
- : new Redis(options);
190
-
191
- subscriber.on("error", (error) => noteFailure("subscriber", error));
192
- subscriber.on("message", (_channel, payload) => dispatch(payload));
193
-
194
- if (subscriber.status !== "ready" && subscriber.status !== "connecting") {
195
- await subscriber.connect();
196
- }
197
- await subscriber.subscribe(channel);
198
- } catch (error) {
199
- console.warn(
200
- "[redis] could not subscribe, invalidation will stay local:",
201
- error instanceof Error ? error.message : error,
202
- );
203
- subscriber = null;
204
- }
205
- }
206
-
207
- /**
208
- * @param {string} payload
209
- */
210
- function dispatch(payload) {
211
- /** @type {any} */
212
- let event;
213
- try {
214
- event = JSON.parse(payload);
215
- } catch {
216
- return;
217
- }
218
-
219
- // Kendi mesajı: yerel iş zaten yayından önce yapıldı.
220
- if (!event || event.originId === originId) return;
221
-
222
- for (const listener of listeners) {
223
- try {
224
- listener(event);
225
- } catch (error) {
226
- console.warn("[redis] cache event listener threw", error);
227
- }
228
- }
229
- }
230
-
231
- /**
232
- * Önbellek olaylarına abone olur. Önbellek modülleri yükleme anında çağırır;
233
- * bağlantı sonradan kurulsa da kayıt geçerli kalır.
234
- *
235
- * Dinleyici **yalnızca yerel** işi yapmalı: yeniden yayın yapan bir dinleyici
236
- * iki node arasında sonsuz mesaj döngüsü üretir.
237
- *
238
- * @param {(event: CacheEvent) => void} listener
239
- */
240
- export function onCacheEvent(listener) {
241
- listeners.push(listener);
242
- }
243
-
244
- /**
245
- * Olayı diğer node'lara duyurur. Ateşle-unut: yayın hatası çağıranı
246
- * etkilemez, yerel invalidation zaten yapıldı.
247
- *
248
- * @param {CacheEvent} event
249
- */
250
- export function publishCacheEvent(event) {
251
- if (!usable() || !settings.events) return;
252
-
253
- Promise.resolve(client.publish(channel, JSON.stringify({ ...event, originId })))
254
- .then(noteSuccess)
255
- .catch((error) => noteFailure("publish", error));
256
- }
257
-
258
- /**
259
- * Bu tür için paylaşım açık mı. `html`/`data` ayrı ayrı kapatılabiliyor:
260
- * veri önbelleğini paylaşmak neredeyse her zaman kazançlı, HTML gövdelerini
261
- * paylaşmak girdi başına yüz kilobayt trafik demek.
262
- *
263
- * @param {"html" | "data"} kind
264
- * @returns {boolean}
265
- */
266
- export function redisShares(kind) {
267
- return usable() && settings[kind] === true;
268
- }
269
-
270
- /** @returns {boolean} */
271
- export function redisSharesEncoded() {
272
- return settings.storeEncoded === true;
273
- }
274
-
275
- /**
276
- * @param {"html" | "data"} kind
277
- * @param {string} key
278
- * @returns {string}
279
- */
280
- export function cacheKey(kind, key) {
281
- return `${prefix}:${kind}:${key}`;
282
- }
283
-
284
- /**
285
- * @param {string} key
286
- * @returns {Promise<any | null>} Girdi yoksa, ayrıştırılamıyorsa ya da Redis
287
- * hata verirse `null` — hepsi "miss" sayılır.
288
- */
289
- export async function redisGetJson(key) {
290
- if (!usable()) return null;
291
-
292
- try {
293
- // `get` dizge döndürür ve ikili gövdeyi bozar. `getBuffer` yoksa (eski
294
- // sahte istemci) düz JSON hâlâ `get` ile okunur.
295
- const raw =
296
- typeof client.getBuffer === "function" ? await client.getBuffer(key) : await client.get(key);
297
- noteSuccess();
298
- return raw == null ? null : decodeCacheValue(raw);
299
- } catch (error) {
300
- noteFailure("get", error);
301
- return null;
302
- }
303
- }
304
-
305
- /**
306
- * @param {string} key
307
- * @param {string | Buffer} payload
308
- * @param {number} ttlMs
309
- * @returns {Promise<void>}
310
- */
311
- function writeRaw(key, payload, ttlMs) {
312
- return Promise.resolve(client.set(key, payload, "PX", Math.ceil(ttlMs)))
313
- .then(noteSuccess)
314
- .catch((error) => noteFailure("set", error));
315
- }
316
-
317
- /**
318
- * Ateşle-unut yazma. İsteğin yanıt yolunda beklenmez: HTML zaten L1'e
319
- * yazıldı, Redis kopyası yalnızca diğer node'lar için. 1 KB ve üstü gövdeler
320
- * brotli ile yazılır; okuma düz JSON'u da kabul eder.
321
- *
322
- * @param {string} key
323
- * @param {unknown} value
324
- * @param {number} ttlMs
325
- */
326
- export function redisSetJson(key, value, ttlMs) {
327
- if (!usable() || !(ttlMs > 0)) return;
328
-
329
- const encoded = encodeCacheValue(value);
330
- if (encoded === null) {
331
- // Serileştirilemeyen değer (döngüsel referans, BigInt) sessizce atlanır;
332
- // L1 kopyası çalışmaya devam eder.
333
- noteFailure("serialize", new Error("not JSON"));
334
- return;
335
- }
336
-
337
- // Küçük gövde senkron döner ki testler `set`'ten hemen sonra kaydı okusun.
338
- // Büyük gövdenin brotli'si MISS yanıtını bekletmez.
339
- if (typeof encoded === "string" || Buffer.isBuffer(encoded)) {
340
- writeRaw(key, encoded, ttlMs);
341
- return;
342
- }
343
-
344
- encoded.then((payload) => writeRaw(key, payload, ttlMs));
345
- }
346
-
347
- /**
348
- * @param {string[]} keys
349
- */
350
- export function redisDrop(keys) {
351
- if (!usable() || !keys.length) return;
352
-
353
- // `UNLINK` silmeyi arka plana atar: bir webhook binlerce anahtar
354
- // düşürdüğünde `DEL` sunucuyu bloklar.
355
- Promise.resolve(client.unlink(...keys))
356
- .then(noteSuccess)
357
- .catch((error) => noteFailure("unlink", error));
358
- }
359
-
360
- /**
361
- * Bir isim alanını tarar ve eşleşen anahtarları düşürür.
362
- *
363
- * `KEYS` **kullanılmaz**: tek komutta tüm keyspace'i tarayıp sunucuyu bloklar.
364
- * `SCAN` kursoru parça parça döner; bu yüzden işlem atomik değil, ama
365
- * invalidation'ın atomik olması gerekmiyor.
366
- *
367
- * @param {"html" | "data"} kind
368
- * @param {(key: string) => boolean} [match] Anahtarın **önek sonrası** kısmına
369
- * uygulanır; verilmezse tür altındaki her şey düşer.
370
- * @returns {Promise<number>} Düşürülen anahtar sayısı.
371
- */
372
- export async function redisDropMatching(kind, match) {
373
- if (!usable()) return 0;
374
-
375
- const base = `${prefix}:${kind}:`;
376
- let cursor = "0";
377
- let dropped = 0;
378
-
379
- try {
380
- do {
381
- const [next, keys] = await client.scan(
382
- cursor,
383
- "MATCH",
384
- `${base}*`,
385
- "COUNT",
386
- 500,
387
- );
388
- cursor = next;
389
-
390
- const doomed = match
391
- ? keys.filter((/** @type {string} */ key) => match(key.slice(base.length)))
392
- : keys;
393
-
394
- if (doomed.length) {
395
- await client.unlink(...doomed);
396
- dropped += doomed.length;
397
- }
398
- } while (cursor !== "0");
399
-
400
- noteSuccess();
401
- } catch (error) {
402
- noteFailure("scan", error);
403
- }
404
-
405
- return dropped;
406
- }
407
-
408
- /**
409
- * Dev raporu için durum özeti. Bağlantı yoksa da güvenle çağrılabilir.
410
- *
411
- * @returns {{ enabled: boolean, connected: boolean, keyPrefix: string,
412
- * buildId: string, errors: number, bypassed: boolean }}
413
- */
414
- export function getRedisStatus() {
415
- const [, , buildId = ""] = prefix ? prefix.split(":") : [];
416
-
417
- return {
418
- enabled: settings.enabled === true,
419
- connected: Boolean(client),
420
- keyPrefix: prefix,
421
- buildId,
422
- errors,
423
- bypassed: Boolean(bypassUntil && Date.now() < bypassUntil),
424
- };
425
- }
426
-
427
- /**
428
- * Bağlantının **nereye** kurulduğu ve hangi ayarlarla çalıştığı.
429
- *
430
- * Şifre asla dönmez: bağlantı URL'i `redis://user:pass@host` biçiminde
431
- * olabiliyor ve panelin işi adresi göstermek, sırrı değil. Ayrıştırılamayan
432
- * bir URL için adres `"custom"` olur — bozuk bir değer teşhis ucunu
433
- * düşürmemeli.
434
- *
435
- * @returns {{ address: string, secure: boolean, db: string | null,
436
- * namespace: string, keyPrefix: string, html: boolean, data: boolean,
437
- * storeEncoded: boolean, events: boolean, commandTimeoutMs: number,
438
- * subscribed: boolean }}
439
- */
440
- export function getRedisDetails() {
441
- let address = "localhost:6379 (ioredis default)";
442
- let secure = false;
443
- /** @type {string | null} */
444
- let db = null;
445
-
446
- if (settings.url) {
447
- try {
448
- const parsed = new URL(settings.url);
449
- address = `${parsed.hostname}:${parsed.port || 6379}`;
450
- secure = parsed.protocol === "rediss:";
451
- const name = parsed.pathname.replace(/^\//, "");
452
- db = name || null;
453
- } catch {
454
- address = "custom";
455
- }
456
- }
457
-
458
- return {
459
- address,
460
- secure,
461
- db,
462
- namespace: settings.namespace,
463
- keyPrefix: settings.keyPrefix,
464
- html: settings.html === true,
465
- data: settings.data === true,
466
- storeEncoded: settings.storeEncoded === true,
467
- events: settings.events === true,
468
- commandTimeoutMs: settings.commandTimeoutMs,
469
- subscribed: Boolean(subscriber),
470
- };
471
- }
472
-
473
- /**
474
- * Paylaşımlı kademede gerçekten **ne durduğunu** sayar: tür başına anahtar
475
- * sayısı ve sunucunun bildirdiği bellek kullanımı.
476
- *
477
- * Ayrı bir çağrı olması gerekiyor. Sayım `SCAN` turu demek ve panelin döküm
478
- * ucu birkaç saniyede bir yenileniyor; her turda tüm keyspace'i taramak
479
- * Redis'i teşhis uğruna yormak olurdu. Panel bunu düğmeye basınca çağırır.
480
- *
481
- * @returns {Promise<{ ok: boolean, html: number, data: number,
482
- * usedMemory: string | null, totalKeys: number | null }>}
483
- */
484
- export async function inspectRedis() {
485
- if (!usable()) return { ok: false, html: 0, data: 0, usedMemory: null, totalKeys: null };
486
-
487
- try {
488
- const [html, data] = await Promise.all([count("html"), count("data")]);
489
-
490
- /** @type {string | null} */
491
- let usedMemory = null;
492
- /** @type {number | null} */
493
- let totalKeys = null;
494
-
495
- try {
496
- const info = await client.info("memory");
497
- usedMemory = /used_memory_human:(\S+)/.exec(String(info))?.[1] ?? null;
498
- totalKeys = Number(await client.dbsize());
499
- } catch {
500
- // `INFO`/`DBSIZE` kısıtlı bir kurulumda (managed Redis) reddedilebilir;
501
- // anahtar sayıları yine geçerli.
502
- }
503
-
504
- noteSuccess();
505
- return { ok: true, html, data, usedMemory, totalKeys };
506
- } catch (error) {
507
- noteFailure("inspect", error);
508
- return { ok: false, html: 0, data: 0, usedMemory: null, totalKeys: null };
509
- }
510
- }
511
-
512
- /**
513
- * @param {"html" | "data"} kind
514
- * @returns {Promise<number>}
515
- */
516
- async function count(kind) {
517
- let cursor = "0";
518
- let total = 0;
519
-
520
- do {
521
- const [next, keys] = await client.scan(
522
- cursor,
523
- "MATCH",
524
- `${prefix}:${kind}:*`,
525
- "COUNT",
526
- 500,
527
- );
528
- cursor = next;
529
- total += keys.length;
530
- } while (cursor !== "0");
531
-
532
- return total;
533
- }
534
-
535
- /**
536
- * Bağlantıları kapatır. `SIGTERM` sonrası uçuştaki komutların bitmesi
537
- * beklenir (`quit`), zorla kesilmez.
538
- *
539
- * @returns {Promise<void>}
540
- */
541
- export async function disconnectRedis() {
542
- const open = [subscriber, client].filter(Boolean);
543
- client = null;
544
- subscriber = null;
545
-
546
- await Promise.all(
547
- open.map(async (connection) => {
548
- try {
549
- await connection.quit();
550
- } catch {
551
- try {
552
- connection.disconnect();
553
- } catch {
554
- // Kapanış hatası önemsiz: süreç zaten sonlanıyor.
555
- }
556
- }
557
- }),
558
- );
559
- }
560
-
561
- /**
562
- * Testler için: sahte bir istemci enjekte eder. Gerçek bir Redis'e bağlanmadan
563
- * serileştirme ve olay yollarının doğrulanabilmesi gerekiyor.
564
- *
565
- * @param {any} fake `null` → katman kapatılır.
566
- * @param {Partial<RedisConfig>} [overrides]
567
- */
568
- export function setRedisClientForTests(fake, overrides = {}) {
569
- client = fake;
570
- subscriber = null;
571
- settings = { ...DEFAULT_REDIS, enabled: Boolean(fake), ...overrides };
572
- prefix = fake ? `${settings.keyPrefix}:${settings.namespace}:test` : "";
573
- channel = `${settings.keyPrefix}:${settings.namespace}:events`;
574
- errors = 0;
575
- consecutiveFailures = 0;
576
- bypassUntil = 0;
577
- }
578
-
579
- /**
580
- * Testler için: abone kanalından gelmiş gibi olay besler. `originId`
581
- * verilmezse uzak bir node varsayılır; kendi kimliğini taşıyan bir yayını
582
- * olduğu gibi geri vermek de mümkün olmalı — eleme testi buna dayanıyor.
583
- *
584
- * @param {CacheEvent} event
585
- */
586
- export function emitRemoteCacheEventForTests(event) {
587
- dispatch(JSON.stringify({ originId: "remote", ...event }));
588
- }
1
+ /**
2
+ * Opsiyonel Redis ikinci kademesi (L2) ve invalidation yayını.
3
+ *
4
+ * Redis **birincil store olmaz**. `html-cache.js` bellekten senkron okuyor,
5
+ * sıkıştırılmış gövdeleri girdiyle birlikte tutuyor ve "bu render purge'den
6
+ * önce mi başladı" sorusunu tek proseste cevaplıyor. Bu makineyi ağın arkasına
7
+ * almak hem her isteğe gecikme ekler hem de yarışları küme çapında hâle
8
+ * getirir. Bunun yerine bellek içi önbellek (L1) aynen kalır ve Redis iki iş
9
+ * yapar:
10
+ *
11
+ * 1. **L1 miss'te render'ı atlatmak.** Yeni ayağa kalkan ya da o yolu hiç
12
+ * görmemiş bir node, başka bir node'un ürettiği HTML'i bulur.
13
+ * 2. **Invalidation'ı yaymak.** Bugün bir webhook yalnızca isteği alan
14
+ * node'un önbelleğini tazeliyor; diğerleri TTL'i bekliyor. Asıl kazanç
15
+ * depolama değil, bu.
16
+ *
17
+ * Sözleşme: Redis yoksa, bağlanamıyorsa ya da bir komut hata verirse
18
+ * **Redis'siz davranış birebir korunur**. Bu modüldeki hiçbir fonksiyon
19
+ * fırlatmaz; okuma `null`, yazma sessiz no-op'a döner.
20
+ */
21
+ import crypto from "node:crypto";
22
+ import { DEFAULT_REDIS } from "../config/defaults.js";
23
+ import { tryImportFromApp } from "../build/resolve-peer.mjs";
24
+ import { getBuildId } from "./assets.js";
25
+ import { decodeCacheValue, encodeCacheValue } from "./cache-blob.js";
26
+
27
+ /**
28
+ * @typedef {import('../config/index.js').RedisConfig} RedisConfig
29
+ *
30
+ * @typedef {{ type: string, [key: string]: unknown }} CacheEvent
31
+ */
32
+
33
+ /** @type {any} */
34
+ let client = null;
35
+ /** @type {any} */
36
+ let subscriber = null;
37
+
38
+ /** @type {RedisConfig} */
39
+ let settings = { ...DEFAULT_REDIS };
40
+
41
+ /** Anahtar öneki `_jskelet:{ns}:{buildId}` — bir kez hesaplanır. */
42
+ let prefix = "";
43
+ let channel = "";
44
+
45
+ /**
46
+ * Bu prosesin kimliği. Kendi yayınladığı mesajı işlemek, yerel invalidation'ı
47
+ * ikinci kez çalıştırmak ve `data:clear` gibi zincirleme olaylarda sonsuz
48
+ * döngü üretmek olurdu.
49
+ */
50
+ const originId = crypto.randomUUID();
51
+
52
+ /** @type {((event: CacheEvent) => void)[]} */
53
+ const listeners = [];
54
+
55
+ let errors = 0;
56
+
57
+ /**
58
+ * Devre kesici. Redis düştüğünde her istek `commandTimeoutMs` beklemesin:
59
+ * art arda gelen hatalardan sonra istemci bir süre baypas edilir. Süre
60
+ * dolduğunda tek bir komut denenir ve başarılıysa sayaç sıfırlanır.
61
+ */
62
+ let consecutiveFailures = 0;
63
+ let bypassUntil = 0;
64
+
65
+ const MAX_FAILURES = 5;
66
+ const BYPASS_MS = 5000;
67
+
68
+ /** @returns {boolean} */
69
+ function usable() {
70
+ if (!client) return false;
71
+ if (bypassUntil && Date.now() < bypassUntil) return false;
72
+ return true;
73
+ }
74
+
75
+ /**
76
+ * @param {string} action
77
+ * @param {unknown} error
78
+ */
79
+ function noteFailure(action, error) {
80
+ errors += 1;
81
+ consecutiveFailures += 1;
82
+
83
+ if (consecutiveFailures >= MAX_FAILURES && !bypassUntil) {
84
+ bypassUntil = Date.now() + BYPASS_MS;
85
+ console.warn(
86
+ `[redis] ${MAX_FAILURES} consecutive failures — bypassing for ${BYPASS_MS}ms`,
87
+ );
88
+ }
89
+
90
+ // Her hata loglanmaz: Redis düştüğünde saniyede yüzlerce satır basardı.
91
+ if (consecutiveFailures <= MAX_FAILURES) {
92
+ console.warn(
93
+ `[redis] ${action} failed`,
94
+ error instanceof Error ? error.message : error,
95
+ );
96
+ }
97
+ }
98
+
99
+ function noteSuccess() {
100
+ consecutiveFailures = 0;
101
+ bypassUntil = 0;
102
+ }
103
+
104
+ /**
105
+ * Bağlantıyı kurar. `createApp()` config yüklendikten sonra bir kez çağırır.
106
+ *
107
+ * `ioredis` opsiyonel peer bağımlılık ve **uygulamanın** node_modules'ünden
108
+ * çözülür: framework `file:`/workspace bağlantısıyla kuruluysa düz bir
109
+ * `import "ioredis"` framework'ün kendi ağacına bakar.
110
+ *
111
+ * @param {import('../config/index.js').ResolvedConfig} config
112
+ * @returns {Promise<boolean>} Bağlantı kuruldu mu.
113
+ */
114
+ export async function connectRedis(config) {
115
+ const redis = config.redis ?? DEFAULT_REDIS;
116
+ if (!redis.enabled) return false;
117
+
118
+ const module = await tryImportFromApp(config.root, "ioredis");
119
+ if (!module) {
120
+ console.warn(
121
+ "[redis] cache.redis.enabled is set but `ioredis` is not installed — " +
122
+ "falling back to the in-process cache.",
123
+ );
124
+ return false;
125
+ }
126
+
127
+ const Redis = module.default ?? module.Redis;
128
+ if (typeof Redis !== "function") {
129
+ console.warn("[redis] `ioredis` did not export a constructor, ignoring it");
130
+ return false;
131
+ }
132
+
133
+ const options = {
134
+ // Bağlantı `createApp()` içinde beklenmez: Redis erişilemezse sunucu yine
135
+ // ayağa kalkmalı, sadece L2 devre dışı kalır.
136
+ lazyConnect: true,
137
+ // Tek bir komut isteği bloklayan adım; kuyrukta birikmesine izin verilmez.
138
+ commandTimeout: redis.commandTimeoutMs,
139
+ maxRetriesPerRequest: 1,
140
+ enableOfflineQueue: false,
141
+ };
142
+
143
+ try {
144
+ client = redis.url ? new Redis(redis.url, options) : new Redis(options);
145
+ // Bağlantı hatası bir `error` dinleyicisi olmadan süreci düşürür.
146
+ client.on("error", (error) => noteFailure("connection", error));
147
+ await client.connect();
148
+ } catch (error) {
149
+ console.warn(
150
+ "[redis] could not connect — falling back to the in-process cache:",
151
+ error instanceof Error ? error.message : error,
152
+ );
153
+ try {
154
+ client?.disconnect();
155
+ } catch {
156
+ // Zaten kopmuş olabilir.
157
+ }
158
+ client = null;
159
+ return false;
160
+ }
161
+
162
+ settings = redis;
163
+ prefix = `${redis.keyPrefix}:${redis.namespace}:${getBuildId()}`;
164
+ // Kanal bilinçli olarak `buildId` **içermez**: deploy sırasında eski ve yeni
165
+ // sürüm yan yana koşuyor ve purge mesajı ikisine de ulaşmalı.
166
+ channel = `${redis.keyPrefix}:${redis.namespace}:events`;
167
+
168
+ if (redis.events) await subscribe(Redis, redis, options);
169
+
170
+ console.log(`[redis] connected — keys under \`${prefix}:\``);
171
+ return true;
172
+ }
173
+
174
+ /**
175
+ * pub/sub için ikinci bir bağlantı: abone olmuş bir istemci normal komut
176
+ * kabul etmez.
177
+ *
178
+ * @param {Function} Redis
179
+ * @param {RedisConfig} redis
180
+ * @param {Record<string, unknown>} options
181
+ */
182
+ async function subscribe(Redis, redis, options) {
183
+ try {
184
+ subscriber =
185
+ typeof client.duplicate === "function"
186
+ ? client.duplicate()
187
+ : redis.url
188
+ ? new Redis(redis.url, options)
189
+ : new Redis(options);
190
+
191
+ subscriber.on("error", (error) => noteFailure("subscriber", error));
192
+ subscriber.on("message", (_channel, payload) => dispatch(payload));
193
+
194
+ if (subscriber.status !== "ready" && subscriber.status !== "connecting") {
195
+ await subscriber.connect();
196
+ }
197
+ await subscriber.subscribe(channel);
198
+ } catch (error) {
199
+ console.warn(
200
+ "[redis] could not subscribe, invalidation will stay local:",
201
+ error instanceof Error ? error.message : error,
202
+ );
203
+ subscriber = null;
204
+ }
205
+ }
206
+
207
+ /**
208
+ * @param {string} payload
209
+ */
210
+ function dispatch(payload) {
211
+ /** @type {any} */
212
+ let event;
213
+ try {
214
+ event = JSON.parse(payload);
215
+ } catch {
216
+ return;
217
+ }
218
+
219
+ // Kendi mesajı: yerel iş zaten yayından önce yapıldı.
220
+ if (!event || event.originId === originId) return;
221
+
222
+ for (const listener of listeners) {
223
+ try {
224
+ listener(event);
225
+ } catch (error) {
226
+ console.warn("[redis] cache event listener threw", error);
227
+ }
228
+ }
229
+ }
230
+
231
+ /**
232
+ * Önbellek olaylarına abone olur. Önbellek modülleri yükleme anında çağırır;
233
+ * bağlantı sonradan kurulsa da kayıt geçerli kalır.
234
+ *
235
+ * Dinleyici **yalnızca yerel** işi yapmalı: yeniden yayın yapan bir dinleyici
236
+ * iki node arasında sonsuz mesaj döngüsü üretir.
237
+ *
238
+ * @param {(event: CacheEvent) => void} listener
239
+ */
240
+ export function onCacheEvent(listener) {
241
+ listeners.push(listener);
242
+ }
243
+
244
+ /**
245
+ * Olayı diğer node'lara duyurur. Ateşle-unut: yayın hatası çağıranı
246
+ * etkilemez, yerel invalidation zaten yapıldı.
247
+ *
248
+ * @param {CacheEvent} event
249
+ */
250
+ export function publishCacheEvent(event) {
251
+ if (!usable() || !settings.events) return;
252
+
253
+ Promise.resolve(client.publish(channel, JSON.stringify({ ...event, originId })))
254
+ .then(noteSuccess)
255
+ .catch((error) => noteFailure("publish", error));
256
+ }
257
+
258
+ /**
259
+ * Bu tür için paylaşım açık mı. `html`/`data` ayrı ayrı kapatılabiliyor:
260
+ * veri önbelleğini paylaşmak neredeyse her zaman kazançlı, HTML gövdelerini
261
+ * paylaşmak girdi başına yüz kilobayt trafik demek.
262
+ *
263
+ * @param {"html" | "data"} kind
264
+ * @returns {boolean}
265
+ */
266
+ export function redisShares(kind) {
267
+ return usable() && settings[kind] === true;
268
+ }
269
+
270
+ /** @returns {boolean} */
271
+ export function redisSharesEncoded() {
272
+ return settings.storeEncoded === true;
273
+ }
274
+
275
+ /**
276
+ * @param {"html" | "data"} kind
277
+ * @param {string} key
278
+ * @returns {string}
279
+ */
280
+ export function cacheKey(kind, key) {
281
+ return `${prefix}:${kind}:${key}`;
282
+ }
283
+
284
+ /**
285
+ * @param {string} key
286
+ * @returns {Promise<any | null>} Girdi yoksa, ayrıştırılamıyorsa ya da Redis
287
+ * hata verirse `null` — hepsi "miss" sayılır.
288
+ */
289
+ export async function redisGetJson(key) {
290
+ if (!usable()) return null;
291
+
292
+ try {
293
+ // `get` dizge döndürür ve ikili gövdeyi bozar. `getBuffer` yoksa (eski
294
+ // sahte istemci) düz JSON hâlâ `get` ile okunur.
295
+ const raw =
296
+ typeof client.getBuffer === "function" ? await client.getBuffer(key) : await client.get(key);
297
+ noteSuccess();
298
+ return raw == null ? null : decodeCacheValue(raw);
299
+ } catch (error) {
300
+ noteFailure("get", error);
301
+ return null;
302
+ }
303
+ }
304
+
305
+ /**
306
+ * @param {string} key
307
+ * @param {string | Buffer} payload
308
+ * @param {number} ttlMs
309
+ * @returns {Promise<void>}
310
+ */
311
+ function writeRaw(key, payload, ttlMs) {
312
+ return Promise.resolve(client.set(key, payload, "PX", Math.ceil(ttlMs)))
313
+ .then(noteSuccess)
314
+ .catch((error) => noteFailure("set", error));
315
+ }
316
+
317
+ /**
318
+ * Ateşle-unut yazma. İsteğin yanıt yolunda beklenmez: HTML zaten L1'e
319
+ * yazıldı, Redis kopyası yalnızca diğer node'lar için. 1 KB ve üstü gövdeler
320
+ * brotli ile yazılır; okuma düz JSON'u da kabul eder.
321
+ *
322
+ * @param {string} key
323
+ * @param {unknown} value
324
+ * @param {number} ttlMs
325
+ */
326
+ export function redisSetJson(key, value, ttlMs) {
327
+ if (!usable() || !(ttlMs > 0)) return;
328
+
329
+ const encoded = encodeCacheValue(value);
330
+ if (encoded === null) {
331
+ // Serileştirilemeyen değer (döngüsel referans, BigInt) sessizce atlanır;
332
+ // L1 kopyası çalışmaya devam eder.
333
+ noteFailure("serialize", new Error("not JSON"));
334
+ return;
335
+ }
336
+
337
+ // Küçük gövde senkron döner ki testler `set`'ten hemen sonra kaydı okusun.
338
+ // Büyük gövdenin brotli'si MISS yanıtını bekletmez.
339
+ if (typeof encoded === "string" || Buffer.isBuffer(encoded)) {
340
+ writeRaw(key, encoded, ttlMs);
341
+ return;
342
+ }
343
+
344
+ encoded.then((payload) => writeRaw(key, payload, ttlMs));
345
+ }
346
+
347
+ /**
348
+ * @param {string[]} keys
349
+ */
350
+ export function redisDrop(keys) {
351
+ if (!usable() || !keys.length) return;
352
+
353
+ // `UNLINK` silmeyi arka plana atar: bir webhook binlerce anahtar
354
+ // düşürdüğünde `DEL` sunucuyu bloklar.
355
+ Promise.resolve(client.unlink(...keys))
356
+ .then(noteSuccess)
357
+ .catch((error) => noteFailure("unlink", error));
358
+ }
359
+
360
+ /**
361
+ * Bir isim alanını tarar ve eşleşen anahtarları düşürür.
362
+ *
363
+ * `KEYS` **kullanılmaz**: tek komutta tüm keyspace'i tarayıp sunucuyu bloklar.
364
+ * `SCAN` kursoru parça parça döner; bu yüzden işlem atomik değil, ama
365
+ * invalidation'ın atomik olması gerekmiyor.
366
+ *
367
+ * @param {"html" | "data"} kind
368
+ * @param {(key: string) => boolean} [match] Anahtarın **önek sonrası** kısmına
369
+ * uygulanır; verilmezse tür altındaki her şey düşer.
370
+ * @returns {Promise<number>} Düşürülen anahtar sayısı.
371
+ */
372
+ export async function redisDropMatching(kind, match) {
373
+ if (!usable()) return 0;
374
+
375
+ const base = `${prefix}:${kind}:`;
376
+ let cursor = "0";
377
+ let dropped = 0;
378
+
379
+ try {
380
+ do {
381
+ const [next, keys] = await client.scan(
382
+ cursor,
383
+ "MATCH",
384
+ `${base}*`,
385
+ "COUNT",
386
+ 500,
387
+ );
388
+ cursor = next;
389
+
390
+ const doomed = match
391
+ ? keys.filter((/** @type {string} */ key) => match(key.slice(base.length)))
392
+ : keys;
393
+
394
+ if (doomed.length) {
395
+ await client.unlink(...doomed);
396
+ dropped += doomed.length;
397
+ }
398
+ } while (cursor !== "0");
399
+
400
+ noteSuccess();
401
+ } catch (error) {
402
+ noteFailure("scan", error);
403
+ }
404
+
405
+ return dropped;
406
+ }
407
+
408
+ /**
409
+ * Dev raporu için durum özeti. Bağlantı yoksa da güvenle çağrılabilir.
410
+ *
411
+ * @returns {{ enabled: boolean, connected: boolean, keyPrefix: string,
412
+ * buildId: string, errors: number, bypassed: boolean }}
413
+ */
414
+ export function getRedisStatus() {
415
+ const [, , buildId = ""] = prefix ? prefix.split(":") : [];
416
+
417
+ return {
418
+ enabled: settings.enabled === true,
419
+ connected: Boolean(client),
420
+ keyPrefix: prefix,
421
+ buildId,
422
+ errors,
423
+ bypassed: Boolean(bypassUntil && Date.now() < bypassUntil),
424
+ };
425
+ }
426
+
427
+ /**
428
+ * Bağlantının **nereye** kurulduğu ve hangi ayarlarla çalıştığı.
429
+ *
430
+ * Şifre asla dönmez: bağlantı URL'i `redis://user:pass@host` biçiminde
431
+ * olabiliyor ve panelin işi adresi göstermek, sırrı değil. Ayrıştırılamayan
432
+ * bir URL için adres `"custom"` olur — bozuk bir değer teşhis ucunu
433
+ * düşürmemeli.
434
+ *
435
+ * @returns {{ address: string, secure: boolean, db: string | null,
436
+ * namespace: string, keyPrefix: string, html: boolean, data: boolean,
437
+ * storeEncoded: boolean, events: boolean, commandTimeoutMs: number,
438
+ * subscribed: boolean }}
439
+ */
440
+ export function getRedisDetails() {
441
+ let address = "localhost:6379 (ioredis default)";
442
+ let secure = false;
443
+ /** @type {string | null} */
444
+ let db = null;
445
+
446
+ if (settings.url) {
447
+ try {
448
+ const parsed = new URL(settings.url);
449
+ address = `${parsed.hostname}:${parsed.port || 6379}`;
450
+ secure = parsed.protocol === "rediss:";
451
+ const name = parsed.pathname.replace(/^\//, "");
452
+ db = name || null;
453
+ } catch {
454
+ address = "custom";
455
+ }
456
+ }
457
+
458
+ return {
459
+ address,
460
+ secure,
461
+ db,
462
+ namespace: settings.namespace,
463
+ keyPrefix: settings.keyPrefix,
464
+ html: settings.html === true,
465
+ data: settings.data === true,
466
+ storeEncoded: settings.storeEncoded === true,
467
+ events: settings.events === true,
468
+ commandTimeoutMs: settings.commandTimeoutMs,
469
+ subscribed: Boolean(subscriber),
470
+ };
471
+ }
472
+
473
+ /**
474
+ * Paylaşımlı kademede gerçekten **ne durduğunu** sayar: tür başına anahtar
475
+ * sayısı ve sunucunun bildirdiği bellek kullanımı.
476
+ *
477
+ * Ayrı bir çağrı olması gerekiyor. Sayım `SCAN` turu demek ve panelin döküm
478
+ * ucu birkaç saniyede bir yenileniyor; her turda tüm keyspace'i taramak
479
+ * Redis'i teşhis uğruna yormak olurdu. Panel bunu düğmeye basınca çağırır.
480
+ *
481
+ * @returns {Promise<{ ok: boolean, html: number, data: number,
482
+ * usedMemory: string | null, totalKeys: number | null }>}
483
+ */
484
+ export async function inspectRedis() {
485
+ if (!usable()) return { ok: false, html: 0, data: 0, usedMemory: null, totalKeys: null };
486
+
487
+ try {
488
+ const [html, data] = await Promise.all([count("html"), count("data")]);
489
+
490
+ /** @type {string | null} */
491
+ let usedMemory = null;
492
+ /** @type {number | null} */
493
+ let totalKeys = null;
494
+
495
+ try {
496
+ const info = await client.info("memory");
497
+ usedMemory = /used_memory_human:(\S+)/.exec(String(info))?.[1] ?? null;
498
+ totalKeys = Number(await client.dbsize());
499
+ } catch {
500
+ // `INFO`/`DBSIZE` kısıtlı bir kurulumda (managed Redis) reddedilebilir;
501
+ // anahtar sayıları yine geçerli.
502
+ }
503
+
504
+ noteSuccess();
505
+ return { ok: true, html, data, usedMemory, totalKeys };
506
+ } catch (error) {
507
+ noteFailure("inspect", error);
508
+ return { ok: false, html: 0, data: 0, usedMemory: null, totalKeys: null };
509
+ }
510
+ }
511
+
512
+ /**
513
+ * @param {"html" | "data"} kind
514
+ * @returns {Promise<number>}
515
+ */
516
+ async function count(kind) {
517
+ let cursor = "0";
518
+ let total = 0;
519
+
520
+ do {
521
+ const [next, keys] = await client.scan(
522
+ cursor,
523
+ "MATCH",
524
+ `${prefix}:${kind}:*`,
525
+ "COUNT",
526
+ 500,
527
+ );
528
+ cursor = next;
529
+ total += keys.length;
530
+ } while (cursor !== "0");
531
+
532
+ return total;
533
+ }
534
+
535
+ /**
536
+ * Bağlantıları kapatır. `SIGTERM` sonrası uçuştaki komutların bitmesi
537
+ * beklenir (`quit`), zorla kesilmez.
538
+ *
539
+ * @returns {Promise<void>}
540
+ */
541
+ export async function disconnectRedis() {
542
+ const open = [subscriber, client].filter(Boolean);
543
+ client = null;
544
+ subscriber = null;
545
+
546
+ await Promise.all(
547
+ open.map(async (connection) => {
548
+ try {
549
+ await connection.quit();
550
+ } catch {
551
+ try {
552
+ connection.disconnect();
553
+ } catch {
554
+ // Kapanış hatası önemsiz: süreç zaten sonlanıyor.
555
+ }
556
+ }
557
+ }),
558
+ );
559
+ }
560
+
561
+ /**
562
+ * Testler için: sahte bir istemci enjekte eder. Gerçek bir Redis'e bağlanmadan
563
+ * serileştirme ve olay yollarının doğrulanabilmesi gerekiyor.
564
+ *
565
+ * @param {any} fake `null` → katman kapatılır.
566
+ * @param {Partial<RedisConfig>} [overrides]
567
+ */
568
+ export function setRedisClientForTests(fake, overrides = {}) {
569
+ client = fake;
570
+ subscriber = null;
571
+ settings = { ...DEFAULT_REDIS, enabled: Boolean(fake), ...overrides };
572
+ prefix = fake ? `${settings.keyPrefix}:${settings.namespace}:test` : "";
573
+ channel = `${settings.keyPrefix}:${settings.namespace}:events`;
574
+ errors = 0;
575
+ consecutiveFailures = 0;
576
+ bypassUntil = 0;
577
+ }
578
+
579
+ /**
580
+ * Testler için: abone kanalından gelmiş gibi olay besler. `originId`
581
+ * verilmezse uzak bir node varsayılır; kendi kimliğini taşıyan bir yayını
582
+ * olduğu gibi geri vermek de mümkün olmalı — eleme testi buna dayanıyor.
583
+ *
584
+ * @param {CacheEvent} event
585
+ */
586
+ export function emitRemoteCacheEventForTests(event) {
587
+ dispatch(JSON.stringify({ originId: "remote", ...event }));
588
+ }