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