jskelet 0.7.1 → 0.7.2
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 +37 -0
- package/docs/04-render-ve-sablonlar.md +7 -6
- package/docs/05-islands.md +8 -4
- package/docs/06-cache.md +18 -16
- package/docs/07-yapilandirma.md +8 -7
- package/docs/en/04-rendering.md +8 -6
- package/docs/en/05-islands.md +8 -4
- package/docs/en/06-caching.md +18 -16
- package/docs/en/07-configuration.md +8 -6
- package/package.json +1 -1
- package/src/build/build.mjs +23 -8
- package/src/build/tasks/precompress.mjs +51 -11
- package/src/client/registry.js +13 -3
- package/src/compile/compile-all.js +14 -8
- package/src/config/defaults.js +9 -5
- package/src/config/index.js +3 -3
- package/src/http/request-cache.js +19 -1
- package/src/index.js +2 -0
- package/src/server/cache-blob.js +33 -0
- package/src/server/create-app.js +5 -0
- package/src/server/data-cache.js +77 -11
- package/src/server/disk-cache.js +2 -2
- package/src/server/head-hints.js +21 -9
- package/src/server/html-cache.js +169 -39
- package/src/server/image-optimizer.js +72 -28
- package/src/server/redis.js +2 -2
- package/src/server/render.js +165 -53
- package/types/client/registry.d.ts +2 -0
- package/types/config/defaults.d.ts +8 -4
- package/types/config/index.d.ts +1 -0
- package/types/index.d.ts +1 -1
- package/types/server/cache-blob.d.ts +8 -0
- package/types/server/data-cache.d.ts +1 -0
- package/types/server/html-cache.d.ts +4 -5
- package/types/server/render.d.ts +19 -0
|
@@ -18,6 +18,24 @@ export function withRequestCache(run) {
|
|
|
18
18
|
return storage.run(new Map(), run);
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
+
/**
|
|
22
|
+
* Sık görülen ilkel argümanlarda `JSON.stringify` atlanır.
|
|
23
|
+
*
|
|
24
|
+
* @param {unknown[]} args
|
|
25
|
+
* @returns {string}
|
|
26
|
+
*/
|
|
27
|
+
function argsKey(args) {
|
|
28
|
+
if (args.length === 0) return "";
|
|
29
|
+
if (args.length === 1) {
|
|
30
|
+
const value = args[0];
|
|
31
|
+
const type = typeof value;
|
|
32
|
+
if (value == null) return `\0${value}`;
|
|
33
|
+
if (type === "string") return `\0s:${value}`;
|
|
34
|
+
if (type === "number" || type === "boolean") return `\0${type}:${value}`;
|
|
35
|
+
}
|
|
36
|
+
return JSON.stringify(args);
|
|
37
|
+
}
|
|
38
|
+
|
|
21
39
|
/**
|
|
22
40
|
* @param {F} fn
|
|
23
41
|
* @returns {F}
|
|
@@ -34,7 +52,7 @@ export function cache(fn) {
|
|
|
34
52
|
contextStore.set(fn, perFn);
|
|
35
53
|
}
|
|
36
54
|
|
|
37
|
-
const key =
|
|
55
|
+
const key = argsKey(args);
|
|
38
56
|
if (perFn.has(key)) return perFn.get(key);
|
|
39
57
|
|
|
40
58
|
const result = fn(...args);
|
package/src/index.js
CHANGED
|
@@ -11,6 +11,8 @@ export {
|
|
|
11
11
|
renderPage,
|
|
12
12
|
renderView,
|
|
13
13
|
renderNotFound,
|
|
14
|
+
warmRenderEngine,
|
|
15
|
+
resetRenderEngine,
|
|
14
16
|
} from "./server/render.js";
|
|
15
17
|
export { renderStatusPage, statusFromError } from "./server/status-page.js";
|
|
16
18
|
export { createApp, startServer } from "./server/create-app.js";
|
package/src/server/cache-blob.js
CHANGED
|
@@ -53,6 +53,9 @@ export function encodeCacheValue(value) {
|
|
|
53
53
|
}
|
|
54
54
|
|
|
55
55
|
/**
|
|
56
|
+
* Senkron çözücü — küçük test / senkron yollar için. Hot path
|
|
57
|
+
* `decodeCacheValueAsync` kullanır (event loop'u brotli ile bloklamaz).
|
|
58
|
+
*
|
|
56
59
|
* @param {Buffer | string} raw
|
|
57
60
|
* @returns {unknown}
|
|
58
61
|
*/
|
|
@@ -68,3 +71,33 @@ export function decodeCacheValue(raw) {
|
|
|
68
71
|
const text = Buffer.isBuffer(raw) ? raw.toString("utf8") : raw;
|
|
69
72
|
return JSON.parse(text);
|
|
70
73
|
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* @param {Buffer | string} raw
|
|
77
|
+
* @returns {Promise<unknown>}
|
|
78
|
+
*/
|
|
79
|
+
export function decodeCacheValueAsync(raw) {
|
|
80
|
+
if (
|
|
81
|
+
Buffer.isBuffer(raw) &&
|
|
82
|
+
raw.length > CACHE_MAGIC.length &&
|
|
83
|
+
raw.subarray(0, CACHE_MAGIC.length).equals(CACHE_MAGIC)
|
|
84
|
+
) {
|
|
85
|
+
const body = raw.subarray(CACHE_MAGIC.length);
|
|
86
|
+
return new Promise((resolve, reject) => {
|
|
87
|
+
zlib.brotliDecompress(body, (error, out) => {
|
|
88
|
+
if (error) {
|
|
89
|
+
reject(error);
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
try {
|
|
93
|
+
resolve(JSON.parse(out.toString("utf8")));
|
|
94
|
+
} catch (parseError) {
|
|
95
|
+
reject(parseError);
|
|
96
|
+
}
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const text = Buffer.isBuffer(raw) ? raw.toString("utf8") : raw;
|
|
102
|
+
return Promise.resolve(JSON.parse(text));
|
|
103
|
+
}
|
package/src/server/create-app.js
CHANGED
|
@@ -247,6 +247,11 @@ export async function startServer(options = {}) {
|
|
|
247
247
|
|
|
248
248
|
const app = await createApp(options);
|
|
249
249
|
|
|
250
|
+
// İlk istek TTFB'sine binmesin: şablon motoru + asset manifest burada ısınır.
|
|
251
|
+
// Sayfa prewarm değildir.
|
|
252
|
+
const { warmRenderEngine } = await import("./render.js");
|
|
253
|
+
await warmRenderEngine();
|
|
254
|
+
|
|
250
255
|
// Varsayılan `::`, `0.0.0.0` değil: ikisi de "tüm arayüzler" demek, ama
|
|
251
256
|
// yalnızca IPv6 soketi çift yığın çalışır ve `localhost`un `::1`e çözüldüğü
|
|
252
257
|
// durumu da kapsar. Tarayıcılar `localhost` için önce `::1` deniyor; sıradan
|
package/src/server/data-cache.js
CHANGED
|
@@ -27,7 +27,7 @@ import { getConfig } from "../config/index.js";
|
|
|
27
27
|
import { DATA_CACHE_BYTE_BUDGET, DEFAULT_DATA_CACHE } from "../config/defaults.js";
|
|
28
28
|
import { recordDependency } from "./cache-deps.js";
|
|
29
29
|
import { diskDrop, diskDropMatching, diskGetJson, diskSetJson, diskShares } from "./disk-cache.js";
|
|
30
|
-
import { invalidateHtmlByDependency } from "./html-cache.js";
|
|
30
|
+
import { earlyRefreshLeadMs, invalidateHtmlByDependency } from "./html-cache.js";
|
|
31
31
|
import {
|
|
32
32
|
cacheKey,
|
|
33
33
|
onCacheEvent,
|
|
@@ -39,9 +39,12 @@ import {
|
|
|
39
39
|
} from "./redis.js";
|
|
40
40
|
|
|
41
41
|
/**
|
|
42
|
-
* @typedef {{ value: unknown, expiresAt: number, staleUntil: number, bytes: number
|
|
42
|
+
* @typedef {{ value: unknown, expiresAt: number, staleUntil: number, bytes: number,
|
|
43
|
+
* produceMs: number }} DataEntry
|
|
43
44
|
*/
|
|
44
45
|
|
|
46
|
+
const DEFAULT_PRODUCE_MS = 500;
|
|
47
|
+
|
|
45
48
|
/** @type {Map<string, DataEntry>} */
|
|
46
49
|
const store = new Map();
|
|
47
50
|
|
|
@@ -60,6 +63,14 @@ let byteBudgetOverride = null;
|
|
|
60
63
|
/** @type {Map<string, Promise<unknown>>} */
|
|
61
64
|
const inflight = new Map();
|
|
62
65
|
|
|
66
|
+
/**
|
|
67
|
+
* L2 miss kısa belleği — boş Redis/disk turunu tekrarlamamak için.
|
|
68
|
+
* @type {Map<string, number>}
|
|
69
|
+
*/
|
|
70
|
+
const sharedMissUntil = new Map();
|
|
71
|
+
const SHARED_MISS_TTL_MS = 2_000;
|
|
72
|
+
const MAX_SHARED_MISS = 2_000;
|
|
73
|
+
|
|
63
74
|
/**
|
|
64
75
|
* Süreç ömrü boyunca biriken sayaçlar.
|
|
65
76
|
*
|
|
@@ -106,9 +117,25 @@ function settings() {
|
|
|
106
117
|
}
|
|
107
118
|
}
|
|
108
119
|
|
|
120
|
+
/**
|
|
121
|
+
* @param {DataEntry} entry
|
|
122
|
+
* @param {number} [now]
|
|
123
|
+
* @returns {boolean}
|
|
124
|
+
*/
|
|
125
|
+
function isEarly(entry, now = Date.now()) {
|
|
126
|
+
if (now >= entry.expiresAt) return false;
|
|
127
|
+
// `storedAt` yok; orijinal TTL = bayat penceresi / staleFactor.
|
|
128
|
+
const staleFactor = settings().staleFactor;
|
|
129
|
+
const staleWindow = Math.max(0, entry.staleUntil - entry.expiresAt);
|
|
130
|
+
const originalTtl =
|
|
131
|
+
staleFactor > 0 ? staleWindow / staleFactor : Math.max(staleWindow, entry.produceMs * 4);
|
|
132
|
+
const lead = earlyRefreshLeadMs(entry.produceMs ?? DEFAULT_PRODUCE_MS, originalTtl);
|
|
133
|
+
return now >= entry.expiresAt - lead;
|
|
134
|
+
}
|
|
135
|
+
|
|
109
136
|
/**
|
|
110
137
|
* @param {string} key
|
|
111
|
-
* @returns {{ value: unknown, stale: boolean } | null}
|
|
138
|
+
* @returns {{ value: unknown, stale: boolean, early: boolean } | null}
|
|
112
139
|
*/
|
|
113
140
|
function read(key) {
|
|
114
141
|
const entry = store.get(key);
|
|
@@ -124,7 +151,12 @@ function read(key) {
|
|
|
124
151
|
store.delete(key);
|
|
125
152
|
store.set(key, entry);
|
|
126
153
|
|
|
127
|
-
|
|
154
|
+
const stale = now >= entry.expiresAt;
|
|
155
|
+
return {
|
|
156
|
+
value: entry.value,
|
|
157
|
+
stale,
|
|
158
|
+
early: !stale && isEarly(entry, now),
|
|
159
|
+
};
|
|
128
160
|
}
|
|
129
161
|
|
|
130
162
|
/**
|
|
@@ -200,8 +232,9 @@ function install(key, entry) {
|
|
|
200
232
|
* @param {unknown} value
|
|
201
233
|
* @param {number} ttlSeconds
|
|
202
234
|
* @param {number} staleFactor
|
|
235
|
+
* @param {number} [produceMs]
|
|
203
236
|
*/
|
|
204
|
-
function write(key, value, ttlSeconds, staleFactor) {
|
|
237
|
+
function write(key, value, ttlSeconds, staleFactor, produceMs = DEFAULT_PRODUCE_MS) {
|
|
205
238
|
const now = Date.now();
|
|
206
239
|
const ttl = ttlSeconds * 1000;
|
|
207
240
|
|
|
@@ -211,8 +244,12 @@ function write(key, value, ttlSeconds, staleFactor) {
|
|
|
211
244
|
expiresAt: now + ttl,
|
|
212
245
|
staleUntil: now + ttl + ttl * staleFactor,
|
|
213
246
|
bytes: valueBytes(value),
|
|
247
|
+
produceMs:
|
|
248
|
+
Number.isFinite(produceMs) && produceMs >= 0 ? Math.round(produceMs) : DEFAULT_PRODUCE_MS,
|
|
214
249
|
};
|
|
215
250
|
|
|
251
|
+
sharedMissUntil.delete(key);
|
|
252
|
+
|
|
216
253
|
// Bütçeye sığmayan değer Redis'e de yazılmaz: bir sonraki istek onu geri
|
|
217
254
|
// alıp yine reddeder. Çağıran sonuç yine `producer`'ın döndürdüğüdür.
|
|
218
255
|
if (!install(key, entry)) return;
|
|
@@ -236,9 +273,25 @@ function write(key, value, ttlSeconds, staleFactor) {
|
|
|
236
273
|
*/
|
|
237
274
|
function promote(key, entry) {
|
|
238
275
|
if (!entry.bytes) entry.bytes = valueBytes(entry.value);
|
|
276
|
+
if (!Number.isFinite(entry.produceMs) || entry.produceMs < 0) {
|
|
277
|
+
entry.produceMs = DEFAULT_PRODUCE_MS;
|
|
278
|
+
}
|
|
279
|
+
sharedMissUntil.delete(key);
|
|
239
280
|
install(key, entry);
|
|
240
281
|
}
|
|
241
282
|
|
|
283
|
+
/**
|
|
284
|
+
* @param {string} key
|
|
285
|
+
* @returns {void}
|
|
286
|
+
*/
|
|
287
|
+
function rememberSharedMiss(key) {
|
|
288
|
+
if (sharedMissUntil.size >= MAX_SHARED_MISS) {
|
|
289
|
+
const oldest = sharedMissUntil.keys().next().value;
|
|
290
|
+
if (oldest !== undefined) sharedMissUntil.delete(oldest);
|
|
291
|
+
}
|
|
292
|
+
sharedMissUntil.set(key, Date.now() + SHARED_MISS_TTL_MS);
|
|
293
|
+
}
|
|
294
|
+
|
|
242
295
|
/**
|
|
243
296
|
* Paylaşımlı kademeden okur.
|
|
244
297
|
*
|
|
@@ -254,13 +307,24 @@ async function readShared(key) {
|
|
|
254
307
|
const onDisk = diskShares("data");
|
|
255
308
|
if (!onRedis && !onDisk) return null;
|
|
256
309
|
|
|
310
|
+
const missUntil = sharedMissUntil.get(key);
|
|
311
|
+
if (missUntil !== undefined) {
|
|
312
|
+
if (Date.now() < missUntil) return null;
|
|
313
|
+
sharedMissUntil.delete(key);
|
|
314
|
+
}
|
|
315
|
+
|
|
257
316
|
const entry = onRedis ? await redisGetJson(cacheKey("data", key)) : await diskGetJson("data", key);
|
|
258
|
-
if (!entry || typeof entry.expiresAt !== "number")
|
|
317
|
+
if (!entry || typeof entry.expiresAt !== "number") {
|
|
318
|
+
rememberSharedMiss(key);
|
|
319
|
+
return null;
|
|
320
|
+
}
|
|
259
321
|
if (Date.now() >= entry.expiresAt) {
|
|
260
322
|
if (onDisk) diskDrop("data", [key]);
|
|
323
|
+
rememberSharedMiss(key);
|
|
261
324
|
return null;
|
|
262
325
|
}
|
|
263
326
|
|
|
327
|
+
sharedMissUntil.delete(key);
|
|
264
328
|
return /** @type {DataEntry} */ (entry);
|
|
265
329
|
}
|
|
266
330
|
|
|
@@ -309,11 +373,13 @@ async function produce(key, ttlSeconds, producer, options, staleFactor) {
|
|
|
309
373
|
}
|
|
310
374
|
|
|
311
375
|
stats.produced += 1;
|
|
376
|
+
const startedAt = Date.now();
|
|
312
377
|
const value = await producer();
|
|
378
|
+
const produceMs = Date.now() - startedAt;
|
|
313
379
|
|
|
314
380
|
const empty = value === undefined || value === null;
|
|
315
381
|
if (!empty || options.storeEmpty === true) {
|
|
316
|
-
write(key, value, ttlSeconds, staleFactor);
|
|
382
|
+
write(key, value, ttlSeconds, staleFactor, produceMs);
|
|
317
383
|
}
|
|
318
384
|
|
|
319
385
|
return value;
|
|
@@ -348,10 +414,9 @@ export async function withDataCache(key, ttlSeconds, producer, options = {}) {
|
|
|
348
414
|
if (hit.stale) stats.stale += 1;
|
|
349
415
|
else stats.hits += 1;
|
|
350
416
|
|
|
351
|
-
// Bayat girdi anında döner; tazeleme arkada yürür
|
|
352
|
-
//
|
|
353
|
-
|
|
354
|
-
if (hit.stale) {
|
|
417
|
+
// Bayat veya erken penceredeki girdi anında döner; tazeleme arkada yürür
|
|
418
|
+
// — HTML cache ile aynı: TTL dolmadan upstream çağrısı bitsin.
|
|
419
|
+
if (hit.stale || hit.early) {
|
|
355
420
|
void refresh(key, ttlSeconds, producer, options).catch((error) => {
|
|
356
421
|
console.error(`[data-cache] background refresh failed: ${key}`, error);
|
|
357
422
|
});
|
|
@@ -445,6 +510,7 @@ function clearLocal(prefix) {
|
|
|
445
510
|
removed.push(...store.keys());
|
|
446
511
|
store.clear();
|
|
447
512
|
storedBytes = 0;
|
|
513
|
+
sharedMissUntil.clear();
|
|
448
514
|
} else {
|
|
449
515
|
for (const key of [...store.keys()]) {
|
|
450
516
|
if (key.startsWith(prefix)) {
|
package/src/server/disk-cache.js
CHANGED
|
@@ -14,7 +14,7 @@ import fs from "node:fs/promises";
|
|
|
14
14
|
import path from "node:path";
|
|
15
15
|
import { getConfig } from "../config/index.js";
|
|
16
16
|
import { getBuildId } from "./assets.js";
|
|
17
|
-
import {
|
|
17
|
+
import { decodeCacheValueAsync, encodeCacheValue } from "./cache-blob.js";
|
|
18
18
|
import { redisShares } from "./redis.js";
|
|
19
19
|
|
|
20
20
|
/** @type {string | null} `null` → config'den çöz. Testler mutlak yol verir. */
|
|
@@ -137,7 +137,7 @@ export async function diskGetJson(kind, key) {
|
|
|
137
137
|
try {
|
|
138
138
|
const framed = unframe(await fs.readFile(file));
|
|
139
139
|
if (!framed) return null;
|
|
140
|
-
return
|
|
140
|
+
return decodeCacheValueAsync(framed.body);
|
|
141
141
|
} catch {
|
|
142
142
|
return null;
|
|
143
143
|
}
|
package/src/server/head-hints.js
CHANGED
|
@@ -24,15 +24,27 @@ let cached = null;
|
|
|
24
24
|
export function preconnectHints() {
|
|
25
25
|
if (cached != null) return cached;
|
|
26
26
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
}
|
|
35
|
-
}
|
|
27
|
+
/** @type {Set<string>} */
|
|
28
|
+
const origins = new Set();
|
|
29
|
+
|
|
30
|
+
for (const origin of getConfig().preconnect) {
|
|
31
|
+
try {
|
|
32
|
+
origins.add(new URL(origin).origin);
|
|
33
|
+
} catch {
|
|
34
|
+
console.warn(`[head] invalid preconnect URL, skipped: ${origin}`);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// CDN asset prefix config'e yazılmadan da preconnect alsın.
|
|
39
|
+
try {
|
|
40
|
+
const prefix = getConfig().build?.assets?.prefix;
|
|
41
|
+
if (prefix) origins.add(new URL(prefix).origin);
|
|
42
|
+
} catch {
|
|
43
|
+
/* geçersiz prefix — config zaten uyarmış olabilir */
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
cached = [...origins]
|
|
47
|
+
.map((origin) => `<link rel="preconnect" href="${esc(origin)}">`)
|
|
36
48
|
.join("");
|
|
37
49
|
|
|
38
50
|
return cached;
|
package/src/server/html-cache.js
CHANGED
|
@@ -39,6 +39,7 @@ import { collectDependencies } from "./cache-deps.js";
|
|
|
39
39
|
import { compilePattern, matchPattern } from "../config/pattern.js";
|
|
40
40
|
import { pathOfCacheKey, publicHost } from "./cache-vary.js";
|
|
41
41
|
import { diskDrop, diskDropMatching, diskGetJson, diskSetJson, diskShares } from "./disk-cache.js";
|
|
42
|
+
import { encodeText } from "./middleware/compression.js";
|
|
42
43
|
import {
|
|
43
44
|
cacheKey,
|
|
44
45
|
onCacheEvent,
|
|
@@ -57,8 +58,9 @@ import {
|
|
|
57
58
|
* yine sorulur; cevabı bu alan taşıyor.
|
|
58
59
|
*
|
|
59
60
|
* `sharedEncodings`: Redis'e en son kaç sıkıştırılmış gövde yazıldığı.
|
|
60
|
-
* `encoded`
|
|
61
|
-
*
|
|
61
|
+
* `encoded` yazmadan sonra arka planda brotli ile doldurulur (ve `sendHtml`
|
|
62
|
+
* ilk yanıtta da yazabilir); `storeEncoded` açıkken harita büyüdüğünde girdi
|
|
63
|
+
* yeniden paylaşılır.
|
|
62
64
|
*
|
|
63
65
|
* `produceMs`: son başarılı üretimin süresi. Erken tazeleme penceresi bundan
|
|
64
66
|
* türetilir; Redis'ten gelen kopyada yoksa varsayılan kullanılır.
|
|
@@ -196,6 +198,19 @@ const purgedDeps = new Map();
|
|
|
196
198
|
|
|
197
199
|
const MAX_PURGED_DEPS = 1000;
|
|
198
200
|
|
|
201
|
+
/**
|
|
202
|
+
* L2'de bulunamayan anahtarlar — kısa süre `readShared` atlanır (boş Redis/
|
|
203
|
+
* disk round-trip + timeout maliyeti). Yazınca düşer.
|
|
204
|
+
*
|
|
205
|
+
* @type {Map<string, number>}
|
|
206
|
+
*/
|
|
207
|
+
const sharedMissUntil = new Map();
|
|
208
|
+
|
|
209
|
+
/** @type {number} */
|
|
210
|
+
const SHARED_MISS_TTL_MS = 2_000;
|
|
211
|
+
|
|
212
|
+
const MAX_SHARED_MISS = 2_000;
|
|
213
|
+
|
|
199
214
|
/**
|
|
200
215
|
* Erken tazeleme lead'i: son render süresinin 2 katı (en az 250 ms), TTL'in
|
|
201
216
|
* yarısından fazla olamaz — kısa TTL'lerde sürekli refresh döngüsü olmasın.
|
|
@@ -337,9 +352,8 @@ function read(key) {
|
|
|
337
352
|
store.delete(key);
|
|
338
353
|
store.set(key, entry);
|
|
339
354
|
|
|
340
|
-
// `encoded` yanıt yolunda
|
|
341
|
-
//
|
|
342
|
-
// tek bir karşılaştırmaya bile girmez.
|
|
355
|
+
// `encoded` yanıt yolunda büyüyebilir (gzip vb.). Kontrol yalnızca
|
|
356
|
+
// `storeEncoded` açıkken yapılır; kapalıyken bu satır karşılaştırmaya girmez.
|
|
343
357
|
if (redisSharesEncoded() && entry.encoded.size !== entry.sharedEncodings) {
|
|
344
358
|
share(key, entry);
|
|
345
359
|
}
|
|
@@ -404,20 +418,43 @@ function share(key, entry) {
|
|
|
404
418
|
* @param {string} key
|
|
405
419
|
* @returns {Promise<HtmlEntry | null>}
|
|
406
420
|
*/
|
|
421
|
+
/**
|
|
422
|
+
* @param {string} key
|
|
423
|
+
* @returns {void}
|
|
424
|
+
*/
|
|
425
|
+
function rememberSharedMiss(key) {
|
|
426
|
+
if (sharedMissUntil.size >= MAX_SHARED_MISS) {
|
|
427
|
+
const oldest = sharedMissUntil.keys().next().value;
|
|
428
|
+
if (oldest !== undefined) sharedMissUntil.delete(oldest);
|
|
429
|
+
}
|
|
430
|
+
sharedMissUntil.set(key, Date.now() + SHARED_MISS_TTL_MS);
|
|
431
|
+
}
|
|
432
|
+
|
|
407
433
|
async function readShared(key) {
|
|
408
434
|
const onRedis = redisShares("html");
|
|
409
435
|
const onDisk = diskShares("html");
|
|
410
436
|
if (!onRedis && !onDisk) return null;
|
|
411
437
|
|
|
438
|
+
const missUntil = sharedMissUntil.get(key);
|
|
439
|
+
if (missUntil !== undefined) {
|
|
440
|
+
if (Date.now() < missUntil) return null;
|
|
441
|
+
sharedMissUntil.delete(key);
|
|
442
|
+
}
|
|
443
|
+
|
|
412
444
|
const payload = onRedis
|
|
413
445
|
? await redisGetJson(cacheKey("html", key))
|
|
414
446
|
: await diskGetJson("html", key);
|
|
415
|
-
if (!payload || typeof payload.html !== "string")
|
|
447
|
+
if (!payload || typeof payload.html !== "string") {
|
|
448
|
+
rememberSharedMiss(key);
|
|
449
|
+
return null;
|
|
450
|
+
}
|
|
416
451
|
if (typeof payload.expiresAt !== "number" || Date.now() >= payload.expiresAt) {
|
|
417
452
|
// Dosyanın TTL'i yok; süresi dolmuş kopya okununca silinir.
|
|
418
453
|
if (onDisk) diskDrop("html", [key]);
|
|
454
|
+
rememberSharedMiss(key);
|
|
419
455
|
return null;
|
|
420
456
|
}
|
|
457
|
+
sharedMissUntil.delete(key);
|
|
421
458
|
|
|
422
459
|
const deps = new Set(Array.isArray(payload.deps) ? payload.deps.map(String) : []);
|
|
423
460
|
const storedAt = Number(payload.storedAt) || 0;
|
|
@@ -466,8 +503,9 @@ async function readShared(key) {
|
|
|
466
503
|
* @param {number} ttlSeconds
|
|
467
504
|
* @param {Set<string> | null} deps Render sırasında okunan veri anahtarları.
|
|
468
505
|
* @param {number} [produceMs] Son üretimin süresi (ms).
|
|
506
|
+
* @returns {Promise<void>}
|
|
469
507
|
*/
|
|
470
|
-
function write(key, value, ttlSeconds, deps = null, produceMs = DEFAULT_PRODUCE_MS) {
|
|
508
|
+
async function write(key, value, ttlSeconds, deps = null, produceMs = DEFAULT_PRODUCE_MS) {
|
|
471
509
|
const now = Date.now();
|
|
472
510
|
|
|
473
511
|
/** @type {HtmlEntry} */
|
|
@@ -487,7 +525,30 @@ function write(key, value, ttlSeconds, deps = null, produceMs = DEFAULT_PRODUCE_
|
|
|
487
525
|
|
|
488
526
|
// Bütçeye sığmayan sayfa Redis'e de yazılmaz: bir sonraki istek onu
|
|
489
527
|
// geri alıp yine reddeder, stringify ise o anki RSS'i şişirir.
|
|
490
|
-
|
|
528
|
+
sharedMissUntil.delete(key);
|
|
529
|
+
if (!install(key, entry)) return;
|
|
530
|
+
|
|
531
|
+
// MISS yanıtı br ile gidebilsin: encode bitmeden dönme. share encode sonrası
|
|
532
|
+
// (storeEncoded açıkken br L2'ye de gider).
|
|
533
|
+
await preencodeEntry(key, entry);
|
|
534
|
+
share(key, entry);
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
/**
|
|
538
|
+
* Yazılan HTML için brotli üretir.
|
|
539
|
+
*
|
|
540
|
+
* @param {string} key
|
|
541
|
+
* @param {HtmlEntry} entry
|
|
542
|
+
* @returns {Promise<void>}
|
|
543
|
+
*/
|
|
544
|
+
async function preencodeEntry(key, entry) {
|
|
545
|
+
try {
|
|
546
|
+
const buffer = await encodeText(entry.html, "br");
|
|
547
|
+
if (store.get(key) !== entry) return;
|
|
548
|
+
rememberHtmlEncoding(entry.encoded, "br", buffer);
|
|
549
|
+
} catch {
|
|
550
|
+
// Encode başarısızsa sonraki sendHtml dener; cache yazımı bozulmaz.
|
|
551
|
+
}
|
|
491
552
|
}
|
|
492
553
|
|
|
493
554
|
/**
|
|
@@ -522,10 +583,8 @@ function install(key, entry) {
|
|
|
522
583
|
}
|
|
523
584
|
|
|
524
585
|
/**
|
|
525
|
-
*
|
|
526
|
-
*
|
|
527
|
-
* LRU'dan düşmüş bir haritaya yazınca bir daha inmeyen bir artık bırakır;
|
|
528
|
-
* store'dan yeniden okumak o artığı taşımaz.
|
|
586
|
+
* Bayt sayacını store'dan yeniden hesaplar. Testler elle `encoded` doldurunca
|
|
587
|
+
* çağırır; hot path `rememberHtmlEncoding` delta kullanır.
|
|
529
588
|
*
|
|
530
589
|
* @returns {void}
|
|
531
590
|
*/
|
|
@@ -612,42 +671,94 @@ function extendStaleWhileRefreshing(key) {
|
|
|
612
671
|
* @returns {Promise<{ html: string, status: number, degraded?: boolean,
|
|
613
672
|
* storable?: boolean }>}
|
|
614
673
|
*/
|
|
674
|
+
/** L2 yanıtı bu sürede gelmezse local produce başlar (yavaş Redis TTFB'si). */
|
|
675
|
+
const SHARED_WAIT_MS = 50;
|
|
676
|
+
|
|
615
677
|
async function produce(key, ttlSeconds, producer, token) {
|
|
616
678
|
const startedAt = Date.now();
|
|
617
679
|
|
|
618
|
-
//
|
|
619
|
-
//
|
|
620
|
-
const
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
680
|
+
// L2 ile yarış: hızlı HIT kullan; yavaşsa local üret. Local hata L2'yi
|
|
681
|
+
// iptal etmez — disk/Redis decode 50 ms'yi aşabilir.
|
|
682
|
+
const sharedPromise = readShared(key);
|
|
683
|
+
const timedOut = await Promise.race([
|
|
684
|
+
sharedPromise.then((shared) => ({ shared, timeout: false })),
|
|
685
|
+
sleep(SHARED_WAIT_MS).then(() => ({ shared: null, timeout: true })),
|
|
686
|
+
]);
|
|
687
|
+
|
|
688
|
+
if (timedOut.shared && tokens.get(key) === token) {
|
|
689
|
+
install(key, timedOut.shared);
|
|
690
|
+
return { html: timedOut.shared.html, status: timedOut.shared.status };
|
|
624
691
|
}
|
|
625
692
|
|
|
626
|
-
// Bağımlılıklar tazelemede de toplanır, ilk üretimde değil sadece: sayfanın
|
|
627
|
-
// okuduğu anahtarlar zamanla değişir (yeni bir widget, kaldırılan bir blok).
|
|
628
693
|
const deps = trackDependencies() ? new Set() : null;
|
|
694
|
+
const runLocal = () =>
|
|
695
|
+
deps ? collectDependencies(deps, producer) : producer();
|
|
696
|
+
|
|
697
|
+
/** @type {{ html: string, status: number, degraded?: boolean, storable?: boolean } | null} */
|
|
698
|
+
let value = null;
|
|
699
|
+
|
|
700
|
+
if (timedOut.timeout) {
|
|
701
|
+
const localSettled = runLocal().then(
|
|
702
|
+
(v) => /** @type {const} */ ({ ok: true, value: v }),
|
|
703
|
+
(err) => /** @type {const} */ ({ ok: false, err }),
|
|
704
|
+
);
|
|
705
|
+
|
|
706
|
+
const winner = await Promise.race([
|
|
707
|
+
sharedPromise.then((shared) =>
|
|
708
|
+
shared
|
|
709
|
+
? /** @type {const} */ ({ source: "shared", shared })
|
|
710
|
+
: /** @type {const} */ ({ source: "miss" }),
|
|
711
|
+
),
|
|
712
|
+
localSettled.then((result) =>
|
|
713
|
+
/** @type {const} */ ({ source: "local", result }),
|
|
714
|
+
),
|
|
715
|
+
]);
|
|
716
|
+
|
|
717
|
+
if (winner.source === "shared" && tokens.get(key) === token) {
|
|
718
|
+
install(key, winner.shared);
|
|
719
|
+
return { html: winner.shared.html, status: winner.shared.status };
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
if (winner.source === "miss") {
|
|
723
|
+
const result = await localSettled;
|
|
724
|
+
if (!result.ok) throw result.err;
|
|
725
|
+
value = result.value;
|
|
726
|
+
} else {
|
|
727
|
+
// Local önce bitti; geç L2 HIT varsa onu tercih et (yazma yok).
|
|
728
|
+
const late = await sharedPromise;
|
|
729
|
+
if (late && tokens.get(key) === token) {
|
|
730
|
+
install(key, late);
|
|
731
|
+
return { html: late.html, status: late.status };
|
|
732
|
+
}
|
|
733
|
+
if (!winner.result.ok) throw winner.result.err;
|
|
734
|
+
value = winner.result.value;
|
|
735
|
+
}
|
|
736
|
+
} else {
|
|
737
|
+
// L2 miss (null) — local üret.
|
|
738
|
+
value = await runLocal();
|
|
739
|
+
}
|
|
629
740
|
|
|
630
|
-
const value = await (deps ? collectDependencies(deps, producer) : producer());
|
|
631
|
-
|
|
632
|
-
// `degraded`: upstream düştüğü için eksik veriyle üretilmiş HTML.
|
|
633
|
-
// Saklanırsa eksik içerik tüm TTL boyunca servis edilir.
|
|
634
|
-
//
|
|
635
|
-
// `storable: false`: çıktı kullanıcıya bağlı (cookie/Authorization
|
|
636
|
-
// okundu). Anahtar yalnızca yol + query olduğu için saklamak, bir
|
|
637
|
-
// kullanıcının HTML'ini bir başkasına servis etmek olur. Paylaşımlı
|
|
638
|
-
// kademede bunun bedeli daha da ağır — bir kullanıcının HTML'i tüm kümeye
|
|
639
|
-
// dağılırdı — bu yüzden kontrol Redis yazımından önce, `write()` içinde.
|
|
640
|
-
//
|
|
641
|
-
// Token uyuşmuyorsa bu tur, sonucu geçersiz kılan bir invalidation'ın
|
|
642
|
-
// öncesinde başlamış demektir; yazmak az önce düşürüleni geri koyardı.
|
|
643
741
|
const valid = tokens.get(key) === token && !readsPurgedData(deps, startedAt);
|
|
644
|
-
if (
|
|
645
|
-
|
|
742
|
+
if (
|
|
743
|
+
value &&
|
|
744
|
+
valid &&
|
|
745
|
+
value.status === 200 &&
|
|
746
|
+
!value.degraded &&
|
|
747
|
+
value.storable !== false
|
|
748
|
+
) {
|
|
749
|
+
await write(key, value, ttlSeconds, deps, Date.now() - startedAt);
|
|
646
750
|
}
|
|
647
751
|
|
|
648
752
|
return value;
|
|
649
753
|
}
|
|
650
754
|
|
|
755
|
+
/** @param {number} ms */
|
|
756
|
+
function sleep(ms) {
|
|
757
|
+
return new Promise((resolve) => {
|
|
758
|
+
setTimeout(resolve, ms).unref?.();
|
|
759
|
+
});
|
|
760
|
+
}
|
|
761
|
+
|
|
651
762
|
/**
|
|
652
763
|
* @param {string} key
|
|
653
764
|
* @param {number} ttlSeconds 0 → cache yok
|
|
@@ -705,6 +816,7 @@ function clearLocal() {
|
|
|
705
816
|
tokens.clear();
|
|
706
817
|
invalidated.clear();
|
|
707
818
|
purgedDeps.clear();
|
|
819
|
+
sharedMissUntil.clear();
|
|
708
820
|
}
|
|
709
821
|
|
|
710
822
|
export function getHtmlCacheSize() {
|
|
@@ -1127,7 +1239,8 @@ export function isHtmlCacheKeyFresh(key) {
|
|
|
1127
1239
|
|
|
1128
1240
|
/**
|
|
1129
1241
|
* Girdide en fazla bir sıkıştırılmış gövde durur. Yeni kodlama eskisinin
|
|
1130
|
-
* yerini alır; ham HTML kalır. Bayt sayacı
|
|
1242
|
+
* yerini alır; ham HTML kalır. Bayt sayacı delta ile güncellenir — harita
|
|
1243
|
+
* LRU'dan düşmüşse (store'da yok) sayaç oynatılmaz.
|
|
1131
1244
|
*
|
|
1132
1245
|
* @param {Map<string, Buffer>} encoded
|
|
1133
1246
|
* @param {string} encoding
|
|
@@ -1135,11 +1248,28 @@ export function isHtmlCacheKeyFresh(key) {
|
|
|
1135
1248
|
* @returns {void}
|
|
1136
1249
|
*/
|
|
1137
1250
|
export function rememberHtmlEncoding(encoded, encoding, buffer) {
|
|
1138
|
-
|
|
1139
|
-
|
|
1251
|
+
let removed = 0;
|
|
1252
|
+
for (const [key, buf] of [...encoded.entries()]) {
|
|
1253
|
+
if (key !== encoding) {
|
|
1254
|
+
removed += buf.length;
|
|
1255
|
+
encoded.delete(key);
|
|
1256
|
+
}
|
|
1140
1257
|
}
|
|
1258
|
+
const previous = encoded.get(encoding);
|
|
1259
|
+
if (previous) removed += previous.length;
|
|
1141
1260
|
encoded.set(encoding, buffer);
|
|
1142
|
-
|
|
1261
|
+
|
|
1262
|
+
let owned = false;
|
|
1263
|
+
for (const entry of store.values()) {
|
|
1264
|
+
if (entry.encoded === encoded) {
|
|
1265
|
+
owned = true;
|
|
1266
|
+
break;
|
|
1267
|
+
}
|
|
1268
|
+
}
|
|
1269
|
+
if (!owned) return;
|
|
1270
|
+
|
|
1271
|
+
storedBytes = Math.max(0, storedBytes - removed + buffer.length);
|
|
1272
|
+
evictOverflow();
|
|
1143
1273
|
}
|
|
1144
1274
|
|
|
1145
1275
|
/**
|