jskelet 0.7.1 → 0.7.3
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 +41 -0
- package/docs/02-mimari.md +11 -3
- package/docs/04-render-ve-sablonlar.md +39 -9
- package/docs/05-islands.md +8 -4
- package/docs/06-cache.md +18 -16
- package/docs/07-yapilandirma.md +8 -7
- package/docs/08-build.md +5 -2
- package/docs/en/02-architecture.md +10 -3
- package/docs/en/04-rendering.md +44 -10
- 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/docs/en/08-build.md +7 -4
- package/package.json +1 -1
- package/src/build/build.mjs +23 -8
- package/src/build/tasks/css.mjs +2 -1
- 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/src/views/helpers/tags.js +38 -9
- 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
- package/types/views/helpers/tags.d.ts +7 -1
package/src/build/tasks/css.mjs
CHANGED
|
@@ -10,7 +10,8 @@
|
|
|
10
10
|
* CLS 0.307) ve aynı ~27 KB her HTML yanıtında tekrar ediyordu. Sıkıştırılmış
|
|
11
11
|
* global sheet'i render-blocking bırakmak hem daha hızlı hem CLS'siz; ikinci
|
|
12
12
|
* ziyarette zaten immutable önbellekten geliyor. Sayfa sheet'leri de
|
|
13
|
-
* render-blocking; yalnızca ilgili sayfada ek maliyet.
|
|
13
|
+
* varsayılan olarak render-blocking; yalnızca ilgili sayfada ek maliyet.
|
|
14
|
+
* Layout `stylesheets({ blocking: false })` ile bu beklemeyi kapatabilir.
|
|
14
15
|
*
|
|
15
16
|
* Tailwind utility'leri global sheet'te kalmalı. Sayfa CSS'inde tam
|
|
16
17
|
* `@import "tailwindcss"` utility çıktısını tekrarlar; sayfaya özel kurallar
|
|
@@ -49,6 +49,33 @@ function collect(dir) {
|
|
|
49
49
|
return files;
|
|
50
50
|
}
|
|
51
51
|
|
|
52
|
+
/**
|
|
53
|
+
* @param {Buffer} source
|
|
54
|
+
* @param {object} options
|
|
55
|
+
* @returns {Promise<Buffer>}
|
|
56
|
+
*/
|
|
57
|
+
function brotli(source, options) {
|
|
58
|
+
return new Promise((resolve, reject) => {
|
|
59
|
+
zlib.brotliCompress(source, options, (error, out) => {
|
|
60
|
+
if (error) reject(error);
|
|
61
|
+
else resolve(out);
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* @param {Buffer} source
|
|
68
|
+
* @returns {Promise<Buffer>}
|
|
69
|
+
*/
|
|
70
|
+
function gzip(source) {
|
|
71
|
+
return new Promise((resolve, reject) => {
|
|
72
|
+
zlib.gzip(source, { level: 9 }, (error, out) => {
|
|
73
|
+
if (error) reject(error);
|
|
74
|
+
else resolve(out);
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
|
|
52
79
|
export async function precompressAssets() {
|
|
53
80
|
// Önceki turdan kalan kopyalar kaynak değişince bayatlamasın.
|
|
54
81
|
for (const file of collect(paths.assets)) {
|
|
@@ -57,21 +84,34 @@ export async function precompressAssets() {
|
|
|
57
84
|
}
|
|
58
85
|
}
|
|
59
86
|
|
|
60
|
-
|
|
61
|
-
|
|
87
|
+
const candidates = collect(paths.assets).filter((file) => {
|
|
88
|
+
try {
|
|
89
|
+
return fs.statSync(file).size >= MIN_BYTES;
|
|
90
|
+
} catch {
|
|
91
|
+
return false;
|
|
92
|
+
}
|
|
93
|
+
});
|
|
62
94
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
95
|
+
const results = await Promise.all(
|
|
96
|
+
candidates.map(async (file) => {
|
|
97
|
+
const source = await fs.promises.readFile(file);
|
|
98
|
+
if (source.length < MIN_BYTES) return { count: 0, saved: 0 };
|
|
66
99
|
|
|
67
|
-
|
|
68
|
-
|
|
100
|
+
const [br, gz] = await Promise.all([brotli(source, BROTLI_OPTIONS), gzip(source)]);
|
|
101
|
+
await Promise.all([
|
|
102
|
+
fs.promises.writeFile(`${file}.br`, br),
|
|
103
|
+
fs.promises.writeFile(`${file}.gz`, gz),
|
|
104
|
+
]);
|
|
69
105
|
|
|
70
|
-
|
|
71
|
-
|
|
106
|
+
return { count: 1, saved: source.length - br.length };
|
|
107
|
+
}),
|
|
108
|
+
);
|
|
72
109
|
|
|
73
|
-
|
|
74
|
-
|
|
110
|
+
let count = 0;
|
|
111
|
+
let saved = 0;
|
|
112
|
+
for (const row of results) {
|
|
113
|
+
count += row.count;
|
|
114
|
+
saved += row.saved;
|
|
75
115
|
}
|
|
76
116
|
|
|
77
117
|
log.detail(`${count} files, ${log.size(saved)}`);
|
package/src/client/registry.js
CHANGED
|
@@ -16,6 +16,8 @@
|
|
|
16
16
|
* `data-island-idle` görünür olsa bile sayfa sakinleşene (load + boş zaman)
|
|
17
17
|
* kadar bekletir. İlk ekranda görünen ama kritik olmayan ağır modüller
|
|
18
18
|
* (ör. grafik kütüphanesi çeken mini grafik) LCP ile yarışmasın diye.
|
|
19
|
+
* Gizli (zero-box) host'larda da aynı yol kullanılır — `hydrate()` onları
|
|
20
|
+
* `mountWhenReady` ile bağlar, hemen `mountIsland` çağırmaz.
|
|
19
21
|
*
|
|
20
22
|
* `data-island-props` JSON ise parse edilip props olarak geçer.
|
|
21
23
|
*/
|
|
@@ -263,13 +265,21 @@ export function hydrate(root = document) {
|
|
|
263
265
|
const boxed = pending.map((node) => node.getClientRects().length > 0);
|
|
264
266
|
|
|
265
267
|
pending.forEach((node, i) => {
|
|
266
|
-
// `
|
|
267
|
-
|
|
268
|
-
if ("islandEager" in node.dataset || !boxed[i]) {
|
|
268
|
+
// `data-island-eager`: görünürlük / idle yok — hemen bağla.
|
|
269
|
+
if ("islandEager" in node.dataset) {
|
|
269
270
|
mountIsland(node);
|
|
270
271
|
return;
|
|
271
272
|
}
|
|
272
273
|
|
|
274
|
+
// `hidden` drawer/dialog'un düzen kutusu yoktur; IntersectionObserver
|
|
275
|
+
// onu asla bildirmez. Gözlemciye vermek yerine bağlarız, ama yol
|
|
276
|
+
// `mountWhenReady` — `data-island-idle` olan gizli host'lar `load` +
|
|
277
|
+
// boş zamana kadar bekler (LCP ile yarışmasın).
|
|
278
|
+
if (!boxed[i]) {
|
|
279
|
+
mountWhenReady(node);
|
|
280
|
+
return;
|
|
281
|
+
}
|
|
282
|
+
|
|
273
283
|
getLazyObserver().observe(node);
|
|
274
284
|
});
|
|
275
285
|
}
|
|
@@ -107,18 +107,24 @@ export async function compileAll(config, _options = {}) {
|
|
|
107
107
|
* @returns {Promise<{ count: number, rebuilt: boolean }>}
|
|
108
108
|
*/
|
|
109
109
|
export async function ensureTemplatesCompiled(config) {
|
|
110
|
-
const viewRoots = getViewRoots(config);
|
|
111
|
-
const jskFiles = discoverJskFiles(viewRoots);
|
|
112
|
-
if (jskFiles.size === 0) {
|
|
113
|
-
return { count: 0, rebuilt: false };
|
|
114
|
-
}
|
|
115
|
-
|
|
116
110
|
const outDir = path.join(config.dirs.generated, "templates");
|
|
117
111
|
const manifestPath = path.join(outDir, "manifest.json");
|
|
118
112
|
const isProd = process.env.NODE_ENV === "production";
|
|
119
113
|
|
|
120
|
-
|
|
121
|
-
|
|
114
|
+
// Prod'da manifest varsa kaynak ağacını hiç gezme — cold start FS maliyeti.
|
|
115
|
+
if (isProd && fs.existsSync(manifestPath)) {
|
|
116
|
+
try {
|
|
117
|
+
const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
|
|
118
|
+
return { count: Object.keys(manifest).length, rebuilt: false };
|
|
119
|
+
} catch {
|
|
120
|
+
return { count: 0, rebuilt: false };
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const viewRoots = getViewRoots(config);
|
|
125
|
+
const jskFiles = discoverJskFiles(viewRoots);
|
|
126
|
+
if (jskFiles.size === 0) {
|
|
127
|
+
return { count: 0, rebuilt: false };
|
|
122
128
|
}
|
|
123
129
|
|
|
124
130
|
let needs = !fs.existsSync(manifestPath);
|
package/src/config/defaults.js
CHANGED
|
@@ -209,6 +209,11 @@ export const DATA_CACHE_MAX_ENTRIES_CEILING = 20_000;
|
|
|
209
209
|
export const DEFAULT_TRANSIENT_RETRY = {
|
|
210
210
|
attempts: 1,
|
|
211
211
|
delayMs: 300,
|
|
212
|
+
/**
|
|
213
|
+
* `true` (varsayılan): ilk transient `notFound`ta hemen 503; retry arka
|
|
214
|
+
* planda data cache'i ısıtır. `false`: yanıt öncesi senkron retry (eski).
|
|
215
|
+
*/
|
|
216
|
+
background: true,
|
|
212
217
|
};
|
|
213
218
|
|
|
214
219
|
/**
|
|
@@ -273,9 +278,8 @@ export const DEFAULT_UPSTREAM_LIMIT = {
|
|
|
273
278
|
* invalidation'ı bütün node'lara yaymak. Tek instance çalışan bir kurulumda
|
|
274
279
|
* kazanç neredeyse yok; bu yüzden `enabled` varsayılan olarak kapalı.
|
|
275
280
|
*
|
|
276
|
-
* `storeEncoded`
|
|
277
|
-
*
|
|
278
|
-
* indirmekten çoğu zaman daha ucuz.
|
|
281
|
+
* `storeEncoded` açık: soğuk replica L2 HIT'te yeniden brotli ödemesin.
|
|
282
|
+
* Boyut artar; tek encoding (br) tutulduğu için maliyet sınırlıdır.
|
|
279
283
|
*/
|
|
280
284
|
export const DEFAULT_REDIS = {
|
|
281
285
|
enabled: false,
|
|
@@ -288,8 +292,8 @@ export const DEFAULT_REDIS = {
|
|
|
288
292
|
html: true,
|
|
289
293
|
/** Veri önbelleği paylaşılsın mı. */
|
|
290
294
|
data: true,
|
|
291
|
-
/** Brotli
|
|
292
|
-
storeEncoded:
|
|
295
|
+
/** Brotli gövdeleri de paylaşılsın mı (yazma sonrası preencode). */
|
|
296
|
+
storeEncoded: true,
|
|
293
297
|
/** pub/sub üzerinden invalidation yayını. */
|
|
294
298
|
events: true,
|
|
295
299
|
/**
|
package/src/config/index.js
CHANGED
|
@@ -140,7 +140,7 @@ const CONFIG_FILE = "jskelet.config.mjs";
|
|
|
140
140
|
* @property {Record<string, unknown>} data Upstream veri önbelleği ayarları.
|
|
141
141
|
* @property {boolean} trackUpstream `fetch` sarılıp geçici hatalar otomatik bildirilsin mi.
|
|
142
142
|
* @property {boolean} trackDependencies Render'ın okuduğu veri anahtarları kaydedilsin mi.
|
|
143
|
-
* @property {{ attempts: number, delayMs: number }} transientRetry
|
|
143
|
+
* @property {{ attempts: number, delayMs: number, background?: boolean }} transientRetry
|
|
144
144
|
* @property {RedisConfig} redis Opsiyonel Redis ikinci kademesi.
|
|
145
145
|
* @property {typeof DEFAULT_UPSTREAM_LIMIT} upstream Upstream hız freni.
|
|
146
146
|
* @property {LogsConfig} logs Kalıcı log sink'leri (dosya + S3).
|
|
@@ -348,7 +348,7 @@ function normalizeRedis(raw) {
|
|
|
348
348
|
keyPrefix: String(source.keyPrefix ?? DEFAULT_REDIS.keyPrefix),
|
|
349
349
|
html: source.html !== false,
|
|
350
350
|
data: source.data !== false,
|
|
351
|
-
storeEncoded: source.storeEncoded
|
|
351
|
+
storeEncoded: source.storeEncoded !== false,
|
|
352
352
|
events: source.events !== false,
|
|
353
353
|
commandTimeoutMs:
|
|
354
354
|
Number.isFinite(timeout) && timeout > 0
|
|
@@ -738,7 +738,7 @@ function normalizeVary(raw) {
|
|
|
738
738
|
* staleWhileRevalidate: number,
|
|
739
739
|
* data: Record<string, unknown>, trackUpstream: boolean,
|
|
740
740
|
* trackDependencies: boolean,
|
|
741
|
-
* transientRetry: { attempts: number, delayMs: number },
|
|
741
|
+
* transientRetry: { attempts: number, delayMs: number, background?: boolean },
|
|
742
742
|
* redis: RedisConfig,
|
|
743
743
|
* upstream: typeof DEFAULT_UPSTREAM_LIMIT,
|
|
744
744
|
* cloudflare: typeof DEFAULT_CLOUDFLARE,
|
|
@@ -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;
|