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.
- package/CHANGELOG.md +44 -0
- package/docs/06-cache.md +132 -1
- package/docs/07-yapilandirma.md +37 -3
- package/docs/09-dev-araclari.md +7 -4
- package/docs/10-dagitim.md +1 -1
- package/docs/en/06-caching.md +138 -1
- package/docs/en/07-configuration.md +37 -3
- package/docs/en/09-dev-tools.md +7 -5
- package/docs/en/10-deployment.md +1 -1
- package/package.json +5 -1
- package/src/build/paths.mjs +26 -1
- package/src/client/devtools/overlay.js +30 -9
- package/src/config/defaults.js +35 -0
- package/src/config/index.js +47 -1
- package/src/index.js +3 -0
- package/src/server/assets.js +28 -0
- package/src/server/create-app.js +97 -14
- package/src/server/data-cache.js +134 -19
- package/src/server/dev/devtools.js +2 -2
- package/src/server/dev/report.js +4 -0
- package/src/server/dev/socket.js +16 -3
- package/src/server/html-cache.js +329 -43
- package/src/server/prewarm.js +56 -0
- package/src/server/redis.js +461 -0
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
package/src/config/defaults.js
CHANGED
|
@@ -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
|
|
package/src/config/index.js
CHANGED
|
@@ -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";
|
package/src/server/assets.js
CHANGED
|
@@ -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
|
package/src/server/create-app.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
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
|
-
|
|
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
|
+
}
|
package/src/server/data-cache.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
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 =
|
|
117
|
-
.
|
|
118
|
-
|
|
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
|
});
|
package/src/server/dev/report.js
CHANGED
|
@@ -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,
|