jskelet 0.7.2 → 0.7.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.
- package/CHANGELOG.md +10 -0
- package/docs/02-mimari.md +11 -3
- package/docs/04-render-ve-sablonlar.md +32 -3
- package/docs/06-cache.md +35 -16
- package/docs/07-yapilandirma.md +10 -8
- package/docs/08-build.md +5 -2
- package/docs/en/02-architecture.md +10 -3
- package/docs/en/04-rendering.md +36 -4
- package/docs/en/06-caching.md +34 -16
- package/docs/en/07-configuration.md +7 -4
- package/docs/en/08-build.md +7 -4
- package/package.json +1 -1
- package/src/build/tasks/css.mjs +2 -1
- package/src/client/admin/i18n.js +4 -0
- package/src/client/admin/panel.js +8 -2
- package/src/config/defaults.js +10 -2
- package/src/config/index.js +7 -1
- package/src/server/html-cache.js +90 -35
- package/src/server/redis.js +332 -10
- package/src/views/helpers/tags.js +38 -9
- package/types/config/defaults.d.ts +10 -2
- package/types/config/index.d.ts +7 -0
- package/types/server/dev/report.d.ts +2 -0
- package/types/server/redis.d.ts +43 -3
- package/types/views/helpers/tags.d.ts +7 -1
package/src/server/html-cache.js
CHANGED
|
@@ -46,8 +46,8 @@ import {
|
|
|
46
46
|
publishCacheEvent,
|
|
47
47
|
redisDrop,
|
|
48
48
|
redisDropMatching,
|
|
49
|
-
|
|
50
|
-
|
|
49
|
+
redisGetHtml,
|
|
50
|
+
redisShareHtml,
|
|
51
51
|
redisShares,
|
|
52
52
|
redisSharesEncoded,
|
|
53
53
|
} from "./redis.js";
|
|
@@ -372,6 +372,10 @@ function read(key) {
|
|
|
372
372
|
* Girdiyi paylaşımlı kademeye yazar. Ateşle-unut: yanıt yolunda beklenmez,
|
|
373
373
|
* L1 kopyası bu isteği zaten karşılıyor.
|
|
374
374
|
*
|
|
375
|
+
* Redis'te gövde meta JSON'unun içinde durmaz. 1 MB HTML + base64 kodlama
|
|
376
|
+
* tek SET'te `commandTimeoutMs`'i aşıyordu ve zaman aşımı veri sayacını da
|
|
377
|
+
* kapatıyordu. Meta küçük kalır; brotli bayt ayrı anahtardadır.
|
|
378
|
+
*
|
|
375
379
|
* @param {string} key
|
|
376
380
|
* @param {HtmlEntry} entry
|
|
377
381
|
*/
|
|
@@ -383,8 +387,12 @@ function share(key, entry) {
|
|
|
383
387
|
const ttlMs = entry.staleUntil - Date.now();
|
|
384
388
|
if (ttlMs <= 0) return;
|
|
385
389
|
|
|
386
|
-
|
|
387
|
-
|
|
390
|
+
if (onRedis) {
|
|
391
|
+
shareOnRedis(key, entry, ttlMs);
|
|
392
|
+
return;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
diskSetJson("html", key, {
|
|
388
396
|
html: entry.html,
|
|
389
397
|
status: entry.status,
|
|
390
398
|
storedAt: entry.storedAt,
|
|
@@ -392,20 +400,41 @@ function share(key, entry) {
|
|
|
392
400
|
staleUntil: entry.staleUntil,
|
|
393
401
|
produceMs: entry.produceMs,
|
|
394
402
|
deps: [...entry.deps],
|
|
403
|
+
});
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
/**
|
|
407
|
+
* @param {string} key
|
|
408
|
+
* @param {HtmlEntry} entry
|
|
409
|
+
* @param {number} ttlMs
|
|
410
|
+
*/
|
|
411
|
+
function shareOnRedis(key, entry, ttlMs) {
|
|
412
|
+
/** @type {Record<string, unknown>} */
|
|
413
|
+
const meta = {
|
|
414
|
+
status: entry.status,
|
|
415
|
+
storedAt: entry.storedAt,
|
|
416
|
+
expiresAt: entry.expiresAt,
|
|
417
|
+
staleUntil: entry.staleUntil,
|
|
418
|
+
produceMs: entry.produceMs,
|
|
419
|
+
deps: [...entry.deps],
|
|
395
420
|
};
|
|
396
421
|
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
422
|
+
// Yanıt brotli'si HTML brotli'si ile aynı bayt (kalite 5, metin). İkinci
|
|
423
|
+
// sıkıştırma yok; `enc:br` storeEncoded açıksa bu tamponun kopyasıdır.
|
|
424
|
+
const encoded = redisSharesEncoded() && entry.encoded.size ? entry.encoded : null;
|
|
425
|
+
if (encoded) entry.sharedEncodings = entry.encoded.size;
|
|
426
|
+
|
|
427
|
+
const ready = entry.encoded.get("br");
|
|
428
|
+
if (ready) {
|
|
429
|
+
redisShareHtml(key, meta, ready, encoded, ttlMs);
|
|
430
|
+
return;
|
|
405
431
|
}
|
|
406
432
|
|
|
407
|
-
|
|
408
|
-
|
|
433
|
+
encodeText(entry.html, "br")
|
|
434
|
+
.then((body) => redisShareHtml(key, meta, body, encoded, ttlMs))
|
|
435
|
+
.catch(() => {
|
|
436
|
+
// Gövdesiz meta okununca kayıt yok sayılır; yarım yazmanın faydası yok.
|
|
437
|
+
});
|
|
409
438
|
}
|
|
410
439
|
|
|
411
440
|
/**
|
|
@@ -441,16 +470,24 @@ async function readShared(key) {
|
|
|
441
470
|
sharedMissUntil.delete(key);
|
|
442
471
|
}
|
|
443
472
|
|
|
444
|
-
const payload = onRedis
|
|
445
|
-
|
|
446
|
-
|
|
473
|
+
const payload = onRedis ? await redisGetHtml(key) : await diskGetJson("html", key);
|
|
474
|
+
return finishShared(key, payload, !onRedis);
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
/**
|
|
478
|
+
* @param {string} key
|
|
479
|
+
* @param {any} payload
|
|
480
|
+
* @param {boolean} fromDisk
|
|
481
|
+
* @returns {HtmlEntry | null}
|
|
482
|
+
*/
|
|
483
|
+
function finishShared(key, payload, fromDisk) {
|
|
447
484
|
if (!payload || typeof payload.html !== "string") {
|
|
448
485
|
rememberSharedMiss(key);
|
|
449
486
|
return null;
|
|
450
487
|
}
|
|
451
488
|
if (typeof payload.expiresAt !== "number" || Date.now() >= payload.expiresAt) {
|
|
452
489
|
// Dosyanın TTL'i yok; süresi dolmuş kopya okununca silinir.
|
|
453
|
-
if (
|
|
490
|
+
if (fromDisk) diskDrop("html", [key]);
|
|
454
491
|
rememberSharedMiss(key);
|
|
455
492
|
return null;
|
|
456
493
|
}
|
|
@@ -464,23 +501,7 @@ async function readShared(key) {
|
|
|
464
501
|
// etmek olurdu.
|
|
465
502
|
if (readsPurgedData(deps, storedAt)) return null;
|
|
466
503
|
|
|
467
|
-
|
|
468
|
-
const encoded = new Map();
|
|
469
|
-
if (payload.encoded && typeof payload.encoded === "object") {
|
|
470
|
-
for (const [encoding, base64] of Object.entries(payload.encoded)) {
|
|
471
|
-
if (typeof base64 === "string") {
|
|
472
|
-
encoded.set(encoding, Buffer.from(base64, "base64"));
|
|
473
|
-
}
|
|
474
|
-
}
|
|
475
|
-
// Eski bir replica iki kodlama yazmış olabilir. L1 tek kopya tutar;
|
|
476
|
-
// brotli varsa o kalır.
|
|
477
|
-
if (encoded.size > 1) {
|
|
478
|
-
const keep = encoded.has("br") ? "br" : encoded.keys().next().value;
|
|
479
|
-
const buffer = keep ? encoded.get(keep) : undefined;
|
|
480
|
-
encoded.clear();
|
|
481
|
-
if (keep && buffer) encoded.set(keep, buffer);
|
|
482
|
-
}
|
|
483
|
-
}
|
|
504
|
+
const encoded = normalizeEncoded(payload.encoded);
|
|
484
505
|
|
|
485
506
|
return {
|
|
486
507
|
html: payload.html,
|
|
@@ -497,6 +518,40 @@ async function readShared(key) {
|
|
|
497
518
|
};
|
|
498
519
|
}
|
|
499
520
|
|
|
521
|
+
/**
|
|
522
|
+
* Disk hâlâ base64 taşır. Redis `Map<string, Buffer>` döner. L1 tek kodlama
|
|
523
|
+
* tutar; brotli varsa o kalır.
|
|
524
|
+
*
|
|
525
|
+
* @param {unknown} raw
|
|
526
|
+
* @returns {Map<string, Buffer>}
|
|
527
|
+
*/
|
|
528
|
+
function normalizeEncoded(raw) {
|
|
529
|
+
/** @type {Map<string, Buffer>} */
|
|
530
|
+
const encoded = new Map();
|
|
531
|
+
|
|
532
|
+
if (raw instanceof Map) {
|
|
533
|
+
for (const [encoding, buffer] of raw) {
|
|
534
|
+
if (typeof encoding === "string" && Buffer.isBuffer(buffer) && buffer.length) {
|
|
535
|
+
encoded.set(encoding, buffer);
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
} else if (raw && typeof raw === "object") {
|
|
539
|
+
for (const [encoding, value] of Object.entries(raw)) {
|
|
540
|
+
if (typeof value === "string") encoded.set(encoding, Buffer.from(value, "base64"));
|
|
541
|
+
else if (Buffer.isBuffer(value) && value.length) encoded.set(encoding, value);
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
if (encoded.size > 1) {
|
|
546
|
+
const keep = encoded.has("br") ? "br" : encoded.keys().next().value;
|
|
547
|
+
const buffer = keep ? encoded.get(keep) : undefined;
|
|
548
|
+
encoded.clear();
|
|
549
|
+
if (keep && buffer) encoded.set(keep, buffer);
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
return encoded;
|
|
553
|
+
}
|
|
554
|
+
|
|
500
555
|
/**
|
|
501
556
|
* @param {string} key
|
|
502
557
|
* @param {{ html: string, status: number }} value
|
package/src/server/redis.js
CHANGED
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
* fırlatmaz; okuma `null`, yazma sessiz no-op'a döner.
|
|
20
20
|
*/
|
|
21
21
|
import crypto from "node:crypto";
|
|
22
|
+
import zlib from "node:zlib";
|
|
22
23
|
import { DEFAULT_REDIS } from "../config/defaults.js";
|
|
23
24
|
import { tryImportFromApp } from "../build/resolve-peer.mjs";
|
|
24
25
|
import { getBuildId } from "./assets.js";
|
|
@@ -32,6 +33,14 @@ import { decodeCacheValueAsync, encodeCacheValue } from "./cache-blob.js";
|
|
|
32
33
|
|
|
33
34
|
/** @type {any} */
|
|
34
35
|
let client = null;
|
|
36
|
+
/**
|
|
37
|
+
* HTML GET/SET. `commandTimeout` ioredis'te istemci genelinde: gövde yazması
|
|
38
|
+
* veri okumasının 200 ms tavanını paylaşırsa yavaş sayfa küçük anahtarları da
|
|
39
|
+
* bekletir. Ayrı bağlantı bu yüzden var.
|
|
40
|
+
*
|
|
41
|
+
* @type {any}
|
|
42
|
+
*/
|
|
43
|
+
let htmlClient = null;
|
|
35
44
|
/** @type {any} */
|
|
36
45
|
let subscriber = null;
|
|
37
46
|
|
|
@@ -53,18 +62,27 @@ const originId = crypto.randomUUID();
|
|
|
53
62
|
const listeners = [];
|
|
54
63
|
|
|
55
64
|
let errors = 0;
|
|
65
|
+
let htmlErrors = 0;
|
|
56
66
|
|
|
57
67
|
/**
|
|
58
68
|
* Devre kesici. Redis düştüğünde her istek `commandTimeoutMs` beklemesin:
|
|
59
69
|
* art arda gelen hatalardan sonra istemci bir süre baypas edilir. Süre
|
|
60
70
|
* dolduğunda tek bir komut denenir ve başarılıysa sayaç sıfırlanır.
|
|
71
|
+
*
|
|
72
|
+
* HTML'in sayacı ayrı. Gövde zaman aşımı `consecutiveFailures`'a yazılmaz:
|
|
73
|
+
* beş yavaş sayfa, veri önbelleğini beş saniye kapatmasın.
|
|
61
74
|
*/
|
|
62
75
|
let consecutiveFailures = 0;
|
|
63
76
|
let bypassUntil = 0;
|
|
77
|
+
let htmlConsecutiveFailures = 0;
|
|
78
|
+
let htmlBypassUntil = 0;
|
|
64
79
|
|
|
65
80
|
const MAX_FAILURES = 5;
|
|
66
81
|
const BYPASS_MS = 5000;
|
|
67
82
|
|
|
83
|
+
/** Yanıt için saklanan kodlamalar. Okuma bunları aynı pipeline'a koyar. */
|
|
84
|
+
const HTML_RESPONSE_ENCODINGS = ["br", "gzip"];
|
|
85
|
+
|
|
68
86
|
/** @returns {boolean} */
|
|
69
87
|
function usable() {
|
|
70
88
|
if (!client) return false;
|
|
@@ -72,6 +90,13 @@ function usable() {
|
|
|
72
90
|
return true;
|
|
73
91
|
}
|
|
74
92
|
|
|
93
|
+
/** @returns {boolean} */
|
|
94
|
+
function htmlUsable() {
|
|
95
|
+
if (!htmlClient) return false;
|
|
96
|
+
if (htmlBypassUntil && Date.now() < htmlBypassUntil) return false;
|
|
97
|
+
return true;
|
|
98
|
+
}
|
|
99
|
+
|
|
75
100
|
/**
|
|
76
101
|
* @param {string} action
|
|
77
102
|
* @param {unknown} error
|
|
@@ -101,6 +126,36 @@ function noteSuccess() {
|
|
|
101
126
|
bypassUntil = 0;
|
|
102
127
|
}
|
|
103
128
|
|
|
129
|
+
/**
|
|
130
|
+
* HTML bağlantısının hataları. Veri sayacına dokunmaz.
|
|
131
|
+
*
|
|
132
|
+
* @param {string} action
|
|
133
|
+
* @param {unknown} error
|
|
134
|
+
*/
|
|
135
|
+
function noteHtmlFailure(action, error) {
|
|
136
|
+
htmlErrors += 1;
|
|
137
|
+
htmlConsecutiveFailures += 1;
|
|
138
|
+
|
|
139
|
+
if (htmlConsecutiveFailures >= MAX_FAILURES && !htmlBypassUntil) {
|
|
140
|
+
htmlBypassUntil = Date.now() + BYPASS_MS;
|
|
141
|
+
console.warn(
|
|
142
|
+
`[redis] ${MAX_FAILURES} consecutive html failures — bypassing html for ${BYPASS_MS}ms`,
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
if (htmlConsecutiveFailures <= MAX_FAILURES) {
|
|
147
|
+
console.warn(
|
|
148
|
+
`[redis] html ${action} failed`,
|
|
149
|
+
error instanceof Error ? error.message : error,
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function noteHtmlSuccess() {
|
|
155
|
+
htmlConsecutiveFailures = 0;
|
|
156
|
+
htmlBypassUntil = 0;
|
|
157
|
+
}
|
|
158
|
+
|
|
104
159
|
/**
|
|
105
160
|
* Bağlantıyı kurar. `createApp()` config yüklendikten sonra bir kez çağırır.
|
|
106
161
|
*
|
|
@@ -135,6 +190,7 @@ export async function connectRedis(config) {
|
|
|
135
190
|
// ayağa kalkmalı, sadece L2 devre dışı kalır.
|
|
136
191
|
lazyConnect: true,
|
|
137
192
|
// Tek bir komut isteği bloklayan adım; kuyrukta birikmesine izin verilmez.
|
|
193
|
+
// Bu tavan veri, yayın ve SCAN içindir. HTML ayrı bağlantıda.
|
|
138
194
|
commandTimeout: redis.commandTimeoutMs,
|
|
139
195
|
maxRetriesPerRequest: 1,
|
|
140
196
|
enableOfflineQueue: false,
|
|
@@ -165,12 +221,52 @@ export async function connectRedis(config) {
|
|
|
165
221
|
// sürüm yan yana koşuyor ve purge mesajı ikisine de ulaşmalı.
|
|
166
222
|
channel = `${redis.keyPrefix}:${redis.namespace}:events`;
|
|
167
223
|
|
|
224
|
+
if (redis.html) await connectHtml(Redis, redis, options);
|
|
168
225
|
if (redis.events) await subscribe(Redis, redis, options);
|
|
169
226
|
|
|
170
227
|
console.log(`[redis] connected — keys under \`${prefix}:\``);
|
|
171
228
|
return true;
|
|
172
229
|
}
|
|
173
230
|
|
|
231
|
+
/**
|
|
232
|
+
* HTML gövdesi için ayrı bağlantı. `duplicate` host, şifre ve db'yi kopyalar;
|
|
233
|
+
* `commandTimeout` ise bu istemciye özel kalır.
|
|
234
|
+
*
|
|
235
|
+
* Bağlanamazsa veri katmanı ayakta kalır — HTML L1'de durur.
|
|
236
|
+
*
|
|
237
|
+
* @param {Function} Redis
|
|
238
|
+
* @param {RedisConfig} redis
|
|
239
|
+
* @param {Record<string, unknown>} options
|
|
240
|
+
*/
|
|
241
|
+
async function connectHtml(Redis, redis, options) {
|
|
242
|
+
const htmlOptions = { ...options, commandTimeout: redis.htmlCommandTimeoutMs };
|
|
243
|
+
|
|
244
|
+
try {
|
|
245
|
+
htmlClient =
|
|
246
|
+
typeof client.duplicate === "function"
|
|
247
|
+
? client.duplicate({ commandTimeout: redis.htmlCommandTimeoutMs })
|
|
248
|
+
: redis.url
|
|
249
|
+
? new Redis(redis.url, htmlOptions)
|
|
250
|
+
: new Redis(htmlOptions);
|
|
251
|
+
|
|
252
|
+
htmlClient.on("error", (error) => noteHtmlFailure("connection", error));
|
|
253
|
+
if (htmlClient.status !== "ready" && htmlClient.status !== "connecting") {
|
|
254
|
+
await htmlClient.connect();
|
|
255
|
+
}
|
|
256
|
+
} catch (error) {
|
|
257
|
+
console.warn(
|
|
258
|
+
"[redis] html connection failed — HTML stays in-process:",
|
|
259
|
+
error instanceof Error ? error.message : error,
|
|
260
|
+
);
|
|
261
|
+
try {
|
|
262
|
+
htmlClient?.disconnect();
|
|
263
|
+
} catch {
|
|
264
|
+
// Zaten kopmuş olabilir.
|
|
265
|
+
}
|
|
266
|
+
htmlClient = null;
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
174
270
|
/**
|
|
175
271
|
* pub/sub için ikinci bir bağlantı: abone olmuş bir istemci normal komut
|
|
176
272
|
* kabul etmez.
|
|
@@ -264,7 +360,8 @@ export function publishCacheEvent(event) {
|
|
|
264
360
|
* @returns {boolean}
|
|
265
361
|
*/
|
|
266
362
|
export function redisShares(kind) {
|
|
267
|
-
|
|
363
|
+
if (settings[kind] !== true) return false;
|
|
364
|
+
return kind === "html" ? htmlUsable() : usable();
|
|
268
365
|
}
|
|
269
366
|
|
|
270
367
|
/** @returns {boolean} */
|
|
@@ -281,6 +378,192 @@ export function cacheKey(kind, key) {
|
|
|
281
378
|
return `${prefix}:${kind}:${key}`;
|
|
282
379
|
}
|
|
283
380
|
|
|
381
|
+
/**
|
|
382
|
+
* HTML kaydını parçalı yazar. Meta küçük JSON'dur; gövde ham brotli baytıdır
|
|
383
|
+
* (base64 yok, JSON içinde ikinci brotli yok). `encoded` varsa her kodlama
|
|
384
|
+
* `:enc:` anahtarına olduğu gibi gider.
|
|
385
|
+
*
|
|
386
|
+
* Ateşle-unut ve tek sayaç adımı: üç SET'ten biri düşerse bu bir hatadır,
|
|
387
|
+
* veri devre kesicisine yazılmaz.
|
|
388
|
+
*
|
|
389
|
+
* @param {string} key Önbellek anahtarı (`yol?query`), öneksiz.
|
|
390
|
+
* @param {Record<string, unknown>} meta
|
|
391
|
+
* @param {Buffer} body
|
|
392
|
+
* @param {Map<string, Buffer> | null} encoded
|
|
393
|
+
* @param {number} ttlMs
|
|
394
|
+
*/
|
|
395
|
+
export function redisShareHtml(key, meta, body, encoded, ttlMs) {
|
|
396
|
+
if (!htmlUsable() || !(ttlMs > 0)) return;
|
|
397
|
+
|
|
398
|
+
/** @type {string} */
|
|
399
|
+
let json;
|
|
400
|
+
try {
|
|
401
|
+
json = JSON.stringify(meta);
|
|
402
|
+
} catch (error) {
|
|
403
|
+
noteHtmlFailure("serialize", error);
|
|
404
|
+
return;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
const base = cacheKey("html", key);
|
|
408
|
+
const px = Math.ceil(ttlMs);
|
|
409
|
+
/** @type {Promise<unknown>[]} */
|
|
410
|
+
const writes = [
|
|
411
|
+
htmlClient.set(base, json, "PX", px),
|
|
412
|
+
htmlClient.set(`${base}:body`, body, "PX", px),
|
|
413
|
+
];
|
|
414
|
+
|
|
415
|
+
if (encoded) {
|
|
416
|
+
for (const [encoding, buffer] of encoded) {
|
|
417
|
+
if (!encoding || !Buffer.isBuffer(buffer) || !buffer.length) continue;
|
|
418
|
+
writes.push(htmlClient.set(`${base}:enc:${encoding}`, buffer, "PX", px));
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
Promise.all(writes)
|
|
423
|
+
.then(() => noteHtmlSuccess())
|
|
424
|
+
.catch((error) => noteHtmlFailure("set", error));
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
/**
|
|
428
|
+
* Meta + gövde (+ açıksa kodlamalar) tek pipeline. Parça eksikse kayıt yok
|
|
429
|
+
* sayılır; bu bir hata değildir. Bağlantı hatası HTML sayacına yazılır.
|
|
430
|
+
*
|
|
431
|
+
* @param {string} key Önbellek anahtarı (`yol?query`), öneksiz.
|
|
432
|
+
* @returns {Promise<{ html: string, status: unknown, storedAt: unknown,
|
|
433
|
+
* expiresAt: unknown, staleUntil: unknown, produceMs: unknown, deps: unknown,
|
|
434
|
+
* encoded: Map<string, Buffer> } | null>}
|
|
435
|
+
*/
|
|
436
|
+
export async function redisGetHtml(key) {
|
|
437
|
+
if (!htmlUsable()) return null;
|
|
438
|
+
|
|
439
|
+
const base = cacheKey("html", key);
|
|
440
|
+
/** @type {string[]} */
|
|
441
|
+
const keys = [base, `${base}:body`];
|
|
442
|
+
if (settings.storeEncoded === true) {
|
|
443
|
+
for (const encoding of HTML_RESPONSE_ENCODINGS) keys.push(`${base}:enc:${encoding}`);
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
try {
|
|
447
|
+
const values = await readHtmlBuffers(keys);
|
|
448
|
+
noteHtmlSuccess();
|
|
449
|
+
|
|
450
|
+
const metaRaw = values[0];
|
|
451
|
+
const bodyRaw = values[1];
|
|
452
|
+
if (metaRaw == null || bodyRaw == null) return null;
|
|
453
|
+
|
|
454
|
+
const metaText = asText(metaRaw);
|
|
455
|
+
if (!metaText) return null;
|
|
456
|
+
const meta = JSON.parse(metaText);
|
|
457
|
+
if (!meta || typeof meta !== "object") return null;
|
|
458
|
+
|
|
459
|
+
const html = await inflateBrotli(bodyRaw);
|
|
460
|
+
/** @type {Map<string, Buffer>} */
|
|
461
|
+
const encoded = new Map();
|
|
462
|
+
if (settings.storeEncoded === true) {
|
|
463
|
+
HTML_RESPONSE_ENCODINGS.forEach((encoding, index) => {
|
|
464
|
+
const buffer = asBuffer(values[2 + index]);
|
|
465
|
+
if (buffer?.length) encoded.set(encoding, buffer);
|
|
466
|
+
});
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
return {
|
|
470
|
+
status: meta.status,
|
|
471
|
+
storedAt: meta.storedAt,
|
|
472
|
+
expiresAt: meta.expiresAt,
|
|
473
|
+
staleUntil: meta.staleUntil,
|
|
474
|
+
produceMs: meta.produceMs,
|
|
475
|
+
deps: meta.deps,
|
|
476
|
+
html,
|
|
477
|
+
encoded,
|
|
478
|
+
};
|
|
479
|
+
} catch (error) {
|
|
480
|
+
noteHtmlFailure("get", error);
|
|
481
|
+
return null;
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
/**
|
|
486
|
+
* @param {string[]} keys
|
|
487
|
+
* @returns {Promise<(Buffer | string | null)[]>}
|
|
488
|
+
*/
|
|
489
|
+
async function readHtmlBuffers(keys) {
|
|
490
|
+
if (typeof htmlClient.pipeline === "function") {
|
|
491
|
+
const pipe = htmlClient.pipeline();
|
|
492
|
+
const getter = typeof pipe.getBuffer === "function" ? "getBuffer" : "get";
|
|
493
|
+
for (const key of keys) pipe[getter](key);
|
|
494
|
+
const rows = await pipe.exec();
|
|
495
|
+
if (!Array.isArray(rows)) return keys.map(() => null);
|
|
496
|
+
return rows.map((row) => {
|
|
497
|
+
if (!row) return null;
|
|
498
|
+
const [error, value] = row;
|
|
499
|
+
if (error) throw error;
|
|
500
|
+
return value ?? null;
|
|
501
|
+
});
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
const getter = typeof htmlClient.getBuffer === "function" ? "getBuffer" : "get";
|
|
505
|
+
return Promise.all(keys.map((key) => htmlClient[getter](key)));
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
/**
|
|
509
|
+
* @param {unknown} value
|
|
510
|
+
* @returns {string | null}
|
|
511
|
+
*/
|
|
512
|
+
function asText(value) {
|
|
513
|
+
if (typeof value === "string") return value;
|
|
514
|
+
if (Buffer.isBuffer(value)) return value.toString("utf8");
|
|
515
|
+
return null;
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
/**
|
|
519
|
+
* @param {unknown} value
|
|
520
|
+
* @returns {Buffer | null}
|
|
521
|
+
*/
|
|
522
|
+
function asBuffer(value) {
|
|
523
|
+
if (Buffer.isBuffer(value)) return value;
|
|
524
|
+
if (typeof value === "string") return Buffer.from(value);
|
|
525
|
+
return null;
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
/**
|
|
529
|
+
* `:body` JSK sarmalayıcısı taşımaz; tamamı brotli'dir.
|
|
530
|
+
*
|
|
531
|
+
* @param {unknown} raw
|
|
532
|
+
* @returns {Promise<string>}
|
|
533
|
+
*/
|
|
534
|
+
function inflateBrotli(raw) {
|
|
535
|
+
const body = asBuffer(raw);
|
|
536
|
+
if (!body?.length) return Promise.reject(new Error("empty html body"));
|
|
537
|
+
|
|
538
|
+
return new Promise((resolve, reject) => {
|
|
539
|
+
zlib.brotliDecompress(body, (error, out) => {
|
|
540
|
+
if (error) reject(error);
|
|
541
|
+
else resolve(out.toString("utf8"));
|
|
542
|
+
});
|
|
543
|
+
});
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
/**
|
|
547
|
+
* `:body` ve `:enc:*` sayfa kaydı değil, gövde parçasıdır.
|
|
548
|
+
*
|
|
549
|
+
* @param {string} key
|
|
550
|
+
* @returns {boolean}
|
|
551
|
+
*/
|
|
552
|
+
function isHtmlFragmentKey(key) {
|
|
553
|
+
return key.endsWith(":body") || key.endsWith(":enc:br") || key.endsWith(":enc:gzip");
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
/**
|
|
557
|
+
* @param {string} key
|
|
558
|
+
* @returns {boolean}
|
|
559
|
+
*/
|
|
560
|
+
function isHtmlMetaKey(key) {
|
|
561
|
+
// `data` anahtarı da `:html:` metnini taşıyabilir; tür segmenti önekten hemen
|
|
562
|
+
// sonra gelir. Parça anahtarları (`:body`, `:enc:`) ayrıca düşürülür.
|
|
563
|
+
if (!prefix || !key.startsWith(`${prefix}:html:`)) return false;
|
|
564
|
+
return !isHtmlFragmentKey(key);
|
|
565
|
+
}
|
|
566
|
+
|
|
284
567
|
/**
|
|
285
568
|
* @param {string} key
|
|
286
569
|
* @returns {Promise<any | null>} Girdi yoksa, ayrıştırılamıyorsa ya da Redis
|
|
@@ -348,13 +631,41 @@ export function redisSetJson(key, value, ttlMs) {
|
|
|
348
631
|
* @param {string[]} keys
|
|
349
632
|
*/
|
|
350
633
|
export function redisDrop(keys) {
|
|
351
|
-
if (!
|
|
634
|
+
if (!keys.length) return;
|
|
635
|
+
|
|
636
|
+
/** @type {string[]} */
|
|
637
|
+
const dataKeys = [];
|
|
638
|
+
/** @type {string[]} */
|
|
639
|
+
const htmlKeys = [];
|
|
640
|
+
|
|
641
|
+
for (const key of keys) {
|
|
642
|
+
if (isHtmlMetaKey(key)) {
|
|
643
|
+
// Gövde ve kodlama anahtarları meta ile birlikte düşmezse 1 MB'lık
|
|
644
|
+
// parça TTL'e kadar kalır. Eksik anahtarda UNLINK no-op'tur.
|
|
645
|
+
htmlKeys.push(key, `${key}:body`, `${key}:enc:br`, `${key}:enc:gzip`);
|
|
646
|
+
} else if (!isHtmlFragmentKey(key)) {
|
|
647
|
+
dataKeys.push(key);
|
|
648
|
+
}
|
|
649
|
+
}
|
|
352
650
|
|
|
651
|
+
if (dataKeys.length && usable()) unlinkKeys(client, dataKeys, noteFailure, noteSuccess);
|
|
652
|
+
if (htmlKeys.length && htmlUsable()) {
|
|
653
|
+
unlinkKeys(htmlClient, htmlKeys, noteHtmlFailure, noteHtmlSuccess);
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
/**
|
|
658
|
+
* @param {any} connection
|
|
659
|
+
* @param {string[]} keys
|
|
660
|
+
* @param {(action: string, error: unknown) => void} fail
|
|
661
|
+
* @param {() => void} succeed
|
|
662
|
+
*/
|
|
663
|
+
function unlinkKeys(connection, keys, fail, succeed) {
|
|
353
664
|
// `UNLINK` silmeyi arka plana atar: bir webhook binlerce anahtar
|
|
354
665
|
// düşürdüğünde `DEL` sunucuyu bloklar.
|
|
355
|
-
Promise.resolve(
|
|
356
|
-
.then(
|
|
357
|
-
.catch((error) =>
|
|
666
|
+
Promise.resolve(connection.unlink(...keys))
|
|
667
|
+
.then(() => succeed())
|
|
668
|
+
.catch((error) => fail("unlink", error));
|
|
358
669
|
}
|
|
359
670
|
|
|
360
671
|
/**
|
|
@@ -409,7 +720,8 @@ export async function redisDropMatching(kind, match) {
|
|
|
409
720
|
* Dev raporu için durum özeti. Bağlantı yoksa da güvenle çağrılabilir.
|
|
410
721
|
*
|
|
411
722
|
* @returns {{ enabled: boolean, connected: boolean, keyPrefix: string,
|
|
412
|
-
* buildId: string, errors: number, bypassed: boolean
|
|
723
|
+
* buildId: string, errors: number, bypassed: boolean, htmlErrors: number,
|
|
724
|
+
* htmlBypassed: boolean }}
|
|
413
725
|
*/
|
|
414
726
|
export function getRedisStatus() {
|
|
415
727
|
const [, , buildId = ""] = prefix ? prefix.split(":") : [];
|
|
@@ -421,6 +733,8 @@ export function getRedisStatus() {
|
|
|
421
733
|
buildId,
|
|
422
734
|
errors,
|
|
423
735
|
bypassed: Boolean(bypassUntil && Date.now() < bypassUntil),
|
|
736
|
+
htmlErrors,
|
|
737
|
+
htmlBypassed: Boolean(htmlBypassUntil && Date.now() < htmlBypassUntil),
|
|
424
738
|
};
|
|
425
739
|
}
|
|
426
740
|
|
|
@@ -435,7 +749,7 @@ export function getRedisStatus() {
|
|
|
435
749
|
* @returns {{ address: string, secure: boolean, db: string | null,
|
|
436
750
|
* namespace: string, keyPrefix: string, html: boolean, data: boolean,
|
|
437
751
|
* storeEncoded: boolean, events: boolean, commandTimeoutMs: number,
|
|
438
|
-
* subscribed: boolean }}
|
|
752
|
+
* htmlCommandTimeoutMs: number, subscribed: boolean }}
|
|
439
753
|
*/
|
|
440
754
|
export function getRedisDetails() {
|
|
441
755
|
let address = "localhost:6379 (ioredis default)";
|
|
@@ -466,6 +780,7 @@ export function getRedisDetails() {
|
|
|
466
780
|
storeEncoded: settings.storeEncoded === true,
|
|
467
781
|
events: settings.events === true,
|
|
468
782
|
commandTimeoutMs: settings.commandTimeoutMs,
|
|
783
|
+
htmlCommandTimeoutMs: settings.htmlCommandTimeoutMs,
|
|
469
784
|
subscribed: Boolean(subscriber),
|
|
470
785
|
};
|
|
471
786
|
}
|
|
@@ -526,7 +841,7 @@ async function count(kind) {
|
|
|
526
841
|
500,
|
|
527
842
|
);
|
|
528
843
|
cursor = next;
|
|
529
|
-
total += keys.length;
|
|
844
|
+
total += kind === "html" ? keys.filter((key) => !isHtmlFragmentKey(key)).length : keys.length;
|
|
530
845
|
} while (cursor !== "0");
|
|
531
846
|
|
|
532
847
|
return total;
|
|
@@ -539,8 +854,9 @@ async function count(kind) {
|
|
|
539
854
|
* @returns {Promise<void>}
|
|
540
855
|
*/
|
|
541
856
|
export async function disconnectRedis() {
|
|
542
|
-
const open = [subscriber, client].filter(Boolean);
|
|
857
|
+
const open = [...new Set([subscriber, htmlClient, client].filter(Boolean))];
|
|
543
858
|
client = null;
|
|
859
|
+
htmlClient = null;
|
|
544
860
|
subscriber = null;
|
|
545
861
|
|
|
546
862
|
await Promise.all(
|
|
@@ -564,16 +880,22 @@ export async function disconnectRedis() {
|
|
|
564
880
|
*
|
|
565
881
|
* @param {any} fake `null` → katman kapatılır.
|
|
566
882
|
* @param {Partial<RedisConfig>} [overrides]
|
|
883
|
+
* @param {any} [htmlFake] Verilmezse `fake` hem veri hem HTML istemcisidir.
|
|
884
|
+
* HTML zaman aşımını veri sayacından ayırmak için ayrı bir sahte verilir.
|
|
567
885
|
*/
|
|
568
|
-
export function setRedisClientForTests(fake, overrides = {}) {
|
|
886
|
+
export function setRedisClientForTests(fake, overrides = {}, htmlFake = fake) {
|
|
569
887
|
client = fake;
|
|
888
|
+
htmlClient = htmlFake;
|
|
570
889
|
subscriber = null;
|
|
571
890
|
settings = { ...DEFAULT_REDIS, enabled: Boolean(fake), ...overrides };
|
|
572
891
|
prefix = fake ? `${settings.keyPrefix}:${settings.namespace}:test` : "";
|
|
573
892
|
channel = `${settings.keyPrefix}:${settings.namespace}:events`;
|
|
574
893
|
errors = 0;
|
|
894
|
+
htmlErrors = 0;
|
|
575
895
|
consecutiveFailures = 0;
|
|
896
|
+
htmlConsecutiveFailures = 0;
|
|
576
897
|
bypassUntil = 0;
|
|
898
|
+
htmlBypassUntil = 0;
|
|
577
899
|
}
|
|
578
900
|
|
|
579
901
|
/**
|