jskelet 0.7.0 → 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.
Files changed (46) hide show
  1. package/CHANGELOG.md +56 -0
  2. package/docs/04-render-ve-sablonlar.md +20 -9
  3. package/docs/05-islands.md +8 -4
  4. package/docs/06-cache.md +18 -16
  5. package/docs/07-yapilandirma.md +8 -7
  6. package/docs/en/04-rendering.md +21 -9
  7. package/docs/en/05-islands.md +8 -4
  8. package/docs/en/06-caching.md +18 -16
  9. package/docs/en/07-configuration.md +8 -6
  10. package/package.json +1 -1
  11. package/src/build/build.mjs +23 -8
  12. package/src/build/tasks/precompress.mjs +51 -11
  13. package/src/client/registry.js +13 -3
  14. package/src/compile/codegen.js +70 -4
  15. package/src/compile/compile-all.js +18 -3
  16. package/src/compile/index.js +6 -1
  17. package/src/config/defaults.js +9 -5
  18. package/src/config/index.js +3 -3
  19. package/src/http/request-cache.js +19 -1
  20. package/src/index.js +2 -0
  21. package/src/server/assets.js +51 -22
  22. package/src/server/cache-blob.js +33 -0
  23. package/src/server/create-app.js +5 -0
  24. package/src/server/data-cache.js +77 -11
  25. package/src/server/disk-cache.js +2 -2
  26. package/src/server/head-hints.js +21 -9
  27. package/src/server/html-cache.js +169 -39
  28. package/src/server/image-optimizer.js +72 -28
  29. package/src/server/redis.js +2 -2
  30. package/src/server/render.js +183 -60
  31. package/src/templates/layout.jsk +4 -4
  32. package/src/templates/layout.render.js +2 -21
  33. package/src/views/components/loader.js +11 -6
  34. package/src/views/helpers/html.js +13 -5
  35. package/src/views/helpers/tags.js +21 -4
  36. package/types/client/registry.d.ts +2 -0
  37. package/types/compile/codegen.d.ts +9 -4
  38. package/types/compile/compile-all.d.ts +3 -1
  39. package/types/compile/index.d.ts +1 -1
  40. package/types/config/defaults.d.ts +8 -4
  41. package/types/config/index.d.ts +1 -0
  42. package/types/index.d.ts +1 -1
  43. package/types/server/cache-blob.d.ts +8 -0
  44. package/types/server/data-cache.d.ts +1 -0
  45. package/types/server/html-cache.d.ts +4 -5
  46. package/types/server/render.d.ts +19 -0
@@ -1,6 +1,10 @@
1
1
  /**
2
2
  * AST → ESM kaynak metni. `eval` / `new Function` yok; çıktı diskteki
3
3
  * `.mjs` dosyasına yazılır ve runtime'da normal `import` ile yüklenir.
4
+ *
5
+ * Statik HTML parçaları bitişik `s +=` yerine tek literal'de birleşir;
6
+ * şablon girintisi (satır sonu içeren salt-whitespace) derlemede düşer.
7
+ * `pre` / `textarea` / `script` / `style` / `code` içinde whitespace korunur.
4
8
  */
5
9
  import { codegenExpr } from "./expr.js";
6
10
  import { CompileError } from "./errors.js";
@@ -10,6 +14,9 @@ import { CompileError } from "./errors.js";
10
14
  * @typedef {import('./parse.js').AttrNode} AttrNode
11
15
  */
12
16
 
17
+ /** İçinde girinti sıkıştırması yapılmayan etiketler. */
18
+ const PRESERVE_WS = new Set(["pre", "textarea", "script", "style", "code"]);
19
+
13
20
  /**
14
21
  * @param {AstNode[]} ast
15
22
  * @param {{ viewId: string, file?: string, knownComponents?: Set<string> }} options
@@ -49,6 +56,21 @@ export function toPascalCase(name) {
49
56
  .join("");
50
57
  }
51
58
 
59
+ /**
60
+ * Şablon girintisini düşür: satır sonu içeren salt-whitespace → boş;
61
+ * yalnızca boşluk/tab → tek boşluk (inline aralık). Koruma alanı dışında.
62
+ *
63
+ * @param {string} value
64
+ * @param {boolean} preserve
65
+ * @returns {string}
66
+ */
67
+ export function collapseTemplateWhitespace(value, preserve) {
68
+ if (preserve || !value) return value;
69
+ if (!/^[\t\n\r\f\v ]+$/.test(value)) return value;
70
+ if (/[\n\r]/.test(value)) return "";
71
+ return " ";
72
+ }
73
+
52
74
  class CodegenContext {
53
75
  /**
54
76
  * @param {{ viewId: string, file?: string, knownComponents?: Set<string> }} options
@@ -71,6 +93,10 @@ class CodegenContext {
71
93
  this.locals = new Set();
72
94
  /** Çocuk gövdesinde `s` yerine kullanılacak değişken. */
73
95
  this.outVar = "s";
96
+ /** Bitişik statik parçalar — flush edilene kadar birikir. */
97
+ this.staticBuf = "";
98
+ /** `pre` vb. iç içe derinliği. */
99
+ this.preserveWs = 0;
74
100
  }
75
101
 
76
102
  /**
@@ -81,9 +107,28 @@ class CodegenContext {
81
107
  }
82
108
 
83
109
  /**
110
+ * Birikmiş statik HTML'i tek `+=` olarak yazar.
111
+ * @returns {void}
112
+ */
113
+ flushStatic() {
114
+ if (!this.staticBuf) return;
115
+ this.line(`${this.outVar} += ${JSON.stringify(this.staticBuf)};`);
116
+ this.staticBuf = "";
117
+ }
118
+
119
+ /**
120
+ * @param {string} text
121
+ */
122
+ appendStatic(text) {
123
+ if (text) this.staticBuf += text;
124
+ }
125
+
126
+ /**
127
+ * Dinamik ifade — önce statik tamponu boşaltır.
84
128
  * @param {string} exprJs
85
129
  */
86
130
  append(exprJs) {
131
+ this.flushStatic();
87
132
  this.line(`${this.outVar} += ${exprJs};`);
88
133
  }
89
134
 
@@ -105,9 +150,14 @@ class CodegenContext {
105
150
  */
106
151
  emit(node) {
107
152
  switch (node.type) {
108
- case "Text":
109
- if (node.value) this.append(JSON.stringify(node.value));
153
+ case "Text": {
154
+ const value = collapseTemplateWhitespace(
155
+ node.value,
156
+ this.preserveWs > 0,
157
+ );
158
+ if (value) this.appendStatic(value);
110
159
  break;
160
+ }
111
161
  case "Interp": {
112
162
  const expr = codegenExpr(node.expr, this.locals);
113
163
  if (node.raw) this.append(`(${expr} ?? "")`);
@@ -139,17 +189,21 @@ class CodegenContext {
139
189
  */
140
190
  emitElement(node) {
141
191
  const bound = node.attrs.some((a) => a.bound);
192
+ const preserve = PRESERVE_WS.has(node.name.toLowerCase());
193
+
142
194
  if (bound) {
143
195
  this.append(
144
196
  `"<${node.name}" + helpers.attrs({${this.attrsObject(node.attrs)}}) + ">"`,
145
197
  );
146
198
  } else {
147
- this.append(JSON.stringify(`<${node.name}${staticAttrString(node.attrs)}>`));
199
+ this.appendStatic(`<${node.name}${staticAttrString(node.attrs)}>`);
148
200
  }
149
201
 
150
202
  if (!node.selfClosing) {
203
+ if (preserve) this.preserveWs += 1;
151
204
  this.emitNodes(node.children);
152
- this.append(JSON.stringify(`</${node.name}>`));
205
+ if (preserve) this.preserveWs -= 1;
206
+ this.appendStatic(`</${node.name}>`);
153
207
  }
154
208
  }
155
209
 
@@ -186,13 +240,18 @@ class CodegenContext {
186
240
 
187
241
  if (node.children.length) {
188
242
  const tmp = this.nextTemp();
243
+ this.flushStatic();
189
244
  this.line(`{`);
190
245
  this.indent += 1;
191
246
  this.line(`let ${tmp} = "";`);
192
247
  const prevOut = this.outVar;
248
+ const prevStatic = this.staticBuf;
193
249
  this.outVar = tmp;
250
+ this.staticBuf = "";
194
251
  this.emitNodes(node.children);
252
+ this.flushStatic();
195
253
  this.outVar = prevOut;
254
+ this.staticBuf = prevStatic;
196
255
  const propsWithChildren = props
197
256
  ? `${props}, children: ${tmp}`
198
257
  : `children: ${tmp}`;
@@ -209,14 +268,17 @@ class CodegenContext {
209
268
  */
210
269
  emitIf(node) {
211
270
  const test = codegenExpr(node.test, this.locals);
271
+ this.flushStatic();
212
272
  this.line(`if (${test}) {`);
213
273
  this.indent += 1;
214
274
  this.emitNodes(node.consequent);
275
+ this.flushStatic();
215
276
  this.indent -= 1;
216
277
  if (node.alternate.length) {
217
278
  this.line(`} else {`);
218
279
  this.indent += 1;
219
280
  this.emitNodes(node.alternate);
281
+ this.flushStatic();
220
282
  this.indent -= 1;
221
283
  }
222
284
  this.line(`}`);
@@ -228,6 +290,7 @@ class CodegenContext {
228
290
  emitEach(node) {
229
291
  const list = codegenExpr(node.list, this.locals);
230
292
  const idx = node.indexName ?? `__i${++this.temp}`;
293
+ this.flushStatic();
231
294
  this.line(`{`);
232
295
  this.indent += 1;
233
296
  this.line(`const __list = ${list} ?? [];`);
@@ -239,6 +302,7 @@ class CodegenContext {
239
302
  this.locals.add(idx);
240
303
  if (node.indexName) this.locals.add(node.indexName);
241
304
  this.emitNodes(node.children);
305
+ this.flushStatic();
242
306
  this.locals = prev;
243
307
  this.indent -= 1;
244
308
  this.line(`}`);
@@ -259,6 +323,8 @@ class CodegenContext {
259
323
 
260
324
  /** @returns {string} */
261
325
  finish() {
326
+ this.flushStatic();
327
+
262
328
  const imports = [...this.includes]
263
329
  .map((id) => {
264
330
  const alias = includeAlias(id);
@@ -3,6 +3,7 @@
3
3
  */
4
4
  import fs from "node:fs";
5
5
  import path from "node:path";
6
+ import process from "node:process";
6
7
  import { parseTemplate } from "./parse.js";
7
8
  import { codegen, normalizeIncludeId } from "./codegen.js";
8
9
  import { CompileError } from "./errors.js";
@@ -97,21 +98,35 @@ export async function compileAll(config, _options = {}) {
97
98
  }
98
99
 
99
100
  /**
100
- * Manifest yoksa veya herhangi bir `.jsk` daha yeniyse yeniden derler.
101
+ * Manifest yoksa veya (dev'de) herhangi bir `.jsk` daha yeniyse yeniden derler.
102
+ * Production'da manifest varsa mtime taraması atlanır — `jskelet build` CI
103
+ * güvencesi; ilk istekte yüzlerce `statSync` cold start'ı şişirmesin.
101
104
  * Dev sunucu restart'ında build watch kaçırmış olsa bile şablonlar güncel kalır.
102
105
  *
103
106
  * @param {{ root: string, dirs: Record<string, string> }} config
104
107
  * @returns {Promise<{ count: number, rebuilt: boolean }>}
105
108
  */
106
109
  export async function ensureTemplatesCompiled(config) {
110
+ const outDir = path.join(config.dirs.generated, "templates");
111
+ const manifestPath = path.join(outDir, "manifest.json");
112
+ const isProd = process.env.NODE_ENV === "production";
113
+
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
+
107
124
  const viewRoots = getViewRoots(config);
108
125
  const jskFiles = discoverJskFiles(viewRoots);
109
126
  if (jskFiles.size === 0) {
110
127
  return { count: 0, rebuilt: false };
111
128
  }
112
129
 
113
- const outDir = path.join(config.dirs.generated, "templates");
114
- const manifestPath = path.join(outDir, "manifest.json");
115
130
  let needs = !fs.existsSync(manifestPath);
116
131
 
117
132
  if (!needs) {
@@ -4,7 +4,12 @@
4
4
  export { CompileError, indexToLocation } from "./errors.js";
5
5
  export { parseExpr, codegenExpr } from "./expr.js";
6
6
  export { parseTemplate, isComponentTag } from "./parse.js";
7
- export { codegen, toPascalCase, normalizeIncludeId } from "./codegen.js";
7
+ export {
8
+ codegen,
9
+ toPascalCase,
10
+ normalizeIncludeId,
11
+ collapseTemplateWhitespace,
12
+ } from "./codegen.js";
8
13
  export {
9
14
  getViewRoots,
10
15
  getComponentDirs,
@@ -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";
@@ -151,6 +151,55 @@ export function getBuildId() {
151
151
  /** @type {Record<string, OptimizedImage> | null} */
152
152
  let images = null;
153
153
 
154
+ /** Prefix uygulanmış kopya — her `optimizedImage` çağrısında map/spread yok. */
155
+ /** @type {Record<string, OptimizedImage> | null} */
156
+ let imagesPrefixed = null;
157
+
158
+ /** @type {string | null} */
159
+ let imagesPrefix = null;
160
+
161
+ /**
162
+ * `images.json` yükler; CDN prefix varsa varyant URL'lerini bir kez materialize eder.
163
+ * @returns {Record<string, OptimizedImage>}
164
+ */
165
+ function loadImages() {
166
+ const prefix = assetPrefix();
167
+ if (!isDev && images && imagesPrefix === prefix) {
168
+ return imagesPrefixed ?? images;
169
+ }
170
+
171
+ /** @type {Record<string, OptimizedImage>} */
172
+ let raw = {};
173
+ try {
174
+ const file = path.join(getConfig().dirs.generated, "images.json");
175
+ raw = JSON.parse(fs.readFileSync(file, "utf8")).images ?? {};
176
+ } catch {
177
+ raw = {};
178
+ }
179
+
180
+ images = raw;
181
+ imagesPrefix = prefix;
182
+
183
+ if (!prefix) {
184
+ imagesPrefixed = raw;
185
+ return raw;
186
+ }
187
+
188
+ /** @type {Record<string, OptimizedImage>} */
189
+ const prefixed = {};
190
+ for (const [key, entry] of Object.entries(raw)) {
191
+ prefixed[key] = {
192
+ ...entry,
193
+ variants: entry.variants.map((variant) => ({
194
+ ...variant,
195
+ url: applyAssetPrefix(prefix, variant.url),
196
+ })),
197
+ };
198
+ }
199
+ imagesPrefixed = prefixed;
200
+ return prefixed;
201
+ }
202
+
154
203
  /**
155
204
  * `build/tasks/images.mjs` çıktısı: kaynak yolundan webp varyantlarına.
156
205
  * Build çalışmadıysa boş kalır ve görseller orijinalleriyle servis edilir.
@@ -159,29 +208,9 @@ let images = null;
159
208
  * @returns {OptimizedImage | undefined}
160
209
  */
161
210
  export function optimizedImage(src) {
162
- if (isDev || !images) {
163
- try {
164
- const file = path.join(getConfig().dirs.generated, "images.json");
165
- images = JSON.parse(fs.readFileSync(file, "utf8")).images ?? {};
166
- } catch {
167
- images = {};
168
- }
169
- }
170
-
211
+ const table = loadImages();
171
212
  if (!src) return undefined;
172
- const entry = images[src];
173
- if (!entry) return undefined;
174
-
175
- const prefix = assetPrefix();
176
- if (!prefix) return entry;
177
-
178
- return {
179
- ...entry,
180
- variants: entry.variants.map((variant) => ({
181
- ...variant,
182
- url: applyAssetPrefix(prefix, variant.url),
183
- })),
184
- };
213
+ return table[src];
185
214
  }
186
215
 
187
216
  /** @type {{ url: string, ids: Set<string> } | null} */
@@ -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)) {