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.
Files changed (42) hide show
  1. package/CHANGELOG.md +41 -0
  2. package/docs/02-mimari.md +11 -3
  3. package/docs/04-render-ve-sablonlar.md +39 -9
  4. package/docs/05-islands.md +8 -4
  5. package/docs/06-cache.md +18 -16
  6. package/docs/07-yapilandirma.md +8 -7
  7. package/docs/08-build.md +5 -2
  8. package/docs/en/02-architecture.md +10 -3
  9. package/docs/en/04-rendering.md +44 -10
  10. package/docs/en/05-islands.md +8 -4
  11. package/docs/en/06-caching.md +18 -16
  12. package/docs/en/07-configuration.md +8 -6
  13. package/docs/en/08-build.md +7 -4
  14. package/package.json +1 -1
  15. package/src/build/build.mjs +23 -8
  16. package/src/build/tasks/css.mjs +2 -1
  17. package/src/build/tasks/precompress.mjs +51 -11
  18. package/src/client/registry.js +13 -3
  19. package/src/compile/compile-all.js +14 -8
  20. package/src/config/defaults.js +9 -5
  21. package/src/config/index.js +3 -3
  22. package/src/http/request-cache.js +19 -1
  23. package/src/index.js +2 -0
  24. package/src/server/cache-blob.js +33 -0
  25. package/src/server/create-app.js +5 -0
  26. package/src/server/data-cache.js +77 -11
  27. package/src/server/disk-cache.js +2 -2
  28. package/src/server/head-hints.js +21 -9
  29. package/src/server/html-cache.js +169 -39
  30. package/src/server/image-optimizer.js +72 -28
  31. package/src/server/redis.js +2 -2
  32. package/src/server/render.js +165 -53
  33. package/src/views/helpers/tags.js +38 -9
  34. package/types/client/registry.d.ts +2 -0
  35. package/types/config/defaults.d.ts +8 -4
  36. package/types/config/index.d.ts +1 -0
  37. package/types/index.d.ts +1 -1
  38. package/types/server/cache-blob.d.ts +8 -0
  39. package/types/server/data-cache.d.ts +1 -0
  40. package/types/server/html-cache.d.ts +4 -5
  41. package/types/server/render.d.ts +19 -0
  42. package/types/views/helpers/tags.d.ts +7 -1
@@ -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
- let count = 0;
61
- let saved = 0;
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
- for (const file of collect(paths.assets)) {
64
- const source = fs.readFileSync(file);
65
- if (source.length < MIN_BYTES) continue;
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
- const br = zlib.brotliCompressSync(source, BROTLI_OPTIONS);
68
- const gz = zlib.gzipSync(source, { level: 9 });
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
- fs.writeFileSync(`${file}.br`, br);
71
- fs.writeFileSync(`${file}.gz`, gz);
106
+ return { count: 1, saved: source.length - br.length };
107
+ }),
108
+ );
72
109
 
73
- count += 1;
74
- saved += source.length - br.length;
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)}`);
@@ -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
- // `hidden` bir drawer/dialog'un düzen kutusu yoktur ve IntersectionObserver
267
- // onu asla bildirmez; bu yüzden gizli olanlar doğrudan bağlanır.
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
- if (fs.existsSync(manifestPath) && isProd) {
121
- return { count: jskFiles.size, rebuilt: false };
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);
@@ -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` kapalı, çünkü sıkıştırılmış gövdeleri de paylaşmak girdi
277
- * başına boyutu iki-üç katına çıkarır ve brotli'yi yeniden üretmek Redis'ten
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/gzip gövdeleri de paylaşılsın mı. */
292
- storeEncoded: false,
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
  /**
@@ -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 === true,
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 = args.length === 0 ? "" : JSON.stringify(args);
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";
@@ -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
+ }
@@ -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
@@ -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 }} DataEntry
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
- return { value: entry.value, stale: now >= entry.expiresAt };
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") return null;
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 ve hatası bu isteği
352
- // etkilemez — çağıran taraf bir şey beklemediği için upstream'in yavaş
353
- // olması sayfaya yansımaz.
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)) {
@@ -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 { decodeCacheValue, encodeCacheValue } from "./cache-blob.js";
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 decodeCacheValue(framed.body);
140
+ return decodeCacheValueAsync(framed.body);
141
141
  } catch {
142
142
  return null;
143
143
  }
@@ -24,15 +24,27 @@ let cached = null;
24
24
  export function preconnectHints() {
25
25
  if (cached != null) return cached;
26
26
 
27
- cached = getConfig()
28
- .preconnect.map((origin) => {
29
- try {
30
- return `<link rel="preconnect" href="${esc(new URL(origin).origin)}">`;
31
- } catch {
32
- console.warn(`[head] invalid preconnect URL, skipped: ${origin}`);
33
- return "";
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;