jskelet 0.1.1 → 0.1.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 (66) hide show
  1. package/AGENTS.md +5 -0
  2. package/CHANGELOG.md +129 -2
  3. package/README.md +21 -7
  4. package/bin/jskelet.mjs +6 -6
  5. package/docs/03-routing.md +48 -9
  6. package/docs/04-render-ve-sablonlar.md +2 -2
  7. package/docs/05-islands.md +59 -6
  8. package/docs/06-cache.md +240 -26
  9. package/docs/07-yapilandirma.md +108 -7
  10. package/docs/08-build.md +4 -4
  11. package/docs/09-dev-araclari.md +5 -0
  12. package/docs/12-panel-ve-oturum.md +384 -0
  13. package/docs/README.md +25 -2
  14. package/docs/en/01-getting-started.md +292 -0
  15. package/docs/en/02-architecture.md +305 -0
  16. package/docs/en/03-routing.md +493 -0
  17. package/docs/en/04-rendering.md +504 -0
  18. package/docs/en/05-islands.md +492 -0
  19. package/docs/en/06-caching.md +640 -0
  20. package/docs/en/07-configuration.md +789 -0
  21. package/docs/en/08-build.md +383 -0
  22. package/docs/en/09-dev-tools.md +314 -0
  23. package/docs/en/10-deployment.md +332 -0
  24. package/docs/en/11-migration.md +360 -0
  25. package/docs/en/12-dashboards-and-sessions.md +392 -0
  26. package/docs/en/README.md +112 -0
  27. package/package.json +4 -2
  28. package/src/build/build.mjs +1 -1
  29. package/src/build/tasks/client.mjs +2 -2
  30. package/src/build/tasks/fonts.mjs +3 -3
  31. package/src/build/tasks/icons.mjs +1 -1
  32. package/src/build/tasks/images.mjs +2 -2
  33. package/src/client/devtools/overlay.js +196 -164
  34. package/src/client/devtools/report.js +96 -96
  35. package/src/client/form.js +192 -0
  36. package/src/client/index.js +10 -1
  37. package/src/client/registry.js +78 -4
  38. package/src/client/swap.js +188 -0
  39. package/src/config/defaults.js +83 -0
  40. package/src/config/index.js +129 -18
  41. package/src/config/pattern.js +1 -1
  42. package/src/dev-server.mjs +1 -1
  43. package/src/http/control-flow.js +16 -1
  44. package/src/http/cookies.js +257 -0
  45. package/src/http/request-context.js +162 -0
  46. package/src/index.js +26 -2
  47. package/src/init.mjs +32 -31
  48. package/src/log.mjs +8 -2
  49. package/src/logo.png +0 -0
  50. package/src/runtime/alias-hooks.mjs +1 -1
  51. package/src/server/assets.js +1 -1
  52. package/src/server/create-app.js +12 -4
  53. package/src/server/data-cache.js +244 -0
  54. package/src/server/dev/devtools.js +6 -2
  55. package/src/server/dev/report.js +8 -1
  56. package/src/server/dev/version-check.mjs +139 -0
  57. package/src/server/head-hints.js +1 -1
  58. package/src/server/html-cache.js +32 -6
  59. package/src/server/middleware/csrf.js +134 -0
  60. package/src/server/prewarm.js +164 -19
  61. package/src/server/render.js +256 -20
  62. package/src/server/router.js +14 -7
  63. package/src/server/status-page.js +1 -1
  64. package/src/version.mjs +9 -4
  65. package/src/views/components/loader.js +1 -1
  66. package/src/views/helpers/tags.js +53 -1
@@ -11,6 +11,12 @@
11
11
  * middleware zinciri normal trafikle bire bir aynı olsun. Hangi yolların
12
12
  * ısıtılacağını uygulama `hooks.prewarmPaths()` ile bildirir; genelde
13
13
  * sitemap üreten fonksiyonun aynısıdır.
14
+ *
15
+ * On binlerce yolluk bir sitede tur bir "damla damla" tarayıcıya dönüşür:
16
+ * `priority` desenleri her turda başa alınır, geri kalan kuyruk turlar
17
+ * arasında kaldığı yerden devam eder (`rotate`) ve `rps` toplam hızı upstream
18
+ * kotasının altında tutar. Amaç, kimse gelmese bile hiçbir sayfanın soğuk
19
+ * kalmaması — ama bunu API'yi düşürmeden yapmak.
14
20
  */
15
21
  import process from "node:process";
16
22
  import { getConfig, hook } from "../config/index.js";
@@ -64,7 +70,7 @@ async function collectPaths() {
64
70
  const paths = await hook("prewarmPaths", []);
65
71
 
66
72
  if (!Array.isArray(paths)) {
67
- console.warn("[prewarm] hooks.prewarmPaths() dizi döndürmeli, yok sayıldı");
73
+ console.warn("[prewarm] hooks.prewarmPaths() must return an array, ignoring it");
68
74
  return [];
69
75
  }
70
76
 
@@ -78,6 +84,100 @@ async function collectPaths() {
78
84
  );
79
85
  }
80
86
 
87
+ /**
88
+ * `cache().prewarm.priority` desenlerine göre sıralar. Eşleşmeyen yollar
89
+ * listenin sonuna, kendi aralarındaki sırayı koruyarak gider — uygulamanın
90
+ * verdiği sıra hâlâ anlamlı olsun.
91
+ *
92
+ * @param {string[]} paths
93
+ * @returns {{ head: string[], tail: string[] }}
94
+ * `head` öncelikli yollar (her turda ısıtılır), `tail` geri kalan kuyruk
95
+ * (turlar arasında dolaşılır).
96
+ */
97
+ function byPriority(paths) {
98
+ const rules = getConfig().prewarmPriority;
99
+ if (!rules.length) return { head: [], tail: paths };
100
+
101
+ /** @type {string[][]} */
102
+ const buckets = rules.map(() => []);
103
+ /** @type {string[]} */
104
+ const tail = [];
105
+
106
+ for (const candidate of paths) {
107
+ const rank = rules.findIndex((rule) => rule.test(candidate));
108
+ if (rank === -1) tail.push(candidate);
109
+ else buckets[rank].push(candidate);
110
+ }
111
+
112
+ return { head: buckets.flat(), tail };
113
+ }
114
+
115
+ /**
116
+ * Kuyruğun kaldığı yer. Periyodik turlar listeyi baştan ısıtıp aynı ilk
117
+ * `max` yolu tekrar tekrar tazelemesin: her tur bir sonraki dilimi alır ve
118
+ * yeterli tur sonunda liste baştan sona ısınır.
119
+ */
120
+ let queueCursor = 0;
121
+
122
+ /**
123
+ * Bir turda ısıtılacak dilimi seçer: önce `priority` eşleşenler, sonra
124
+ * kuyruğun sırası gelen parçası. Dışa açık olması bilinçli — sıralama ve
125
+ * rotasyon, tur çalışmadan doğrulanabilen tek davranış.
126
+ *
127
+ * @param {string[]} all
128
+ * @param {number} limit
129
+ * @param {boolean} rotate
130
+ * @returns {string[]}
131
+ */
132
+ export function selectPrewarmPaths(all, limit, rotate = true) {
133
+ if (all.length <= limit) return all;
134
+
135
+ const { head, tail } = byPriority(all);
136
+ const selected = head.slice(0, limit);
137
+ const room = limit - selected.length;
138
+ if (room <= 0 || !tail.length) return selected;
139
+
140
+ if (!rotate) return [...selected, ...tail.slice(0, room)];
141
+
142
+ // Dilim listenin sonunu aşarsa başa sarar: kuyruk halkasal dolaşılır.
143
+ const start = queueCursor % tail.length;
144
+ const slice = tail.slice(start, start + room);
145
+ if (slice.length < room) slice.push(...tail.slice(0, room - slice.length));
146
+ queueCursor = (start + room) % tail.length;
147
+
148
+ return [...selected, ...slice];
149
+ }
150
+
151
+ /** @param {number} ms */
152
+ function sleep(ms) {
153
+ return new Promise((resolve) => {
154
+ setTimeout(resolve, ms).unref?.();
155
+ });
156
+ }
157
+
158
+ /**
159
+ * Saniyedeki istek sayısını sınırlar. Fren `concurrency`'den bağımsız
160
+ * olmalı: paralellik gecikmeyi kapatmak için var, kotayı koruyan şey toplam
161
+ * hız. İşçiler aynı sayacı paylaştığı için sıra kimde olursa olsun tur
162
+ * verilen hızın üstüne çıkmaz.
163
+ *
164
+ * @param {number} rps 0 → sınırsız
165
+ * @returns {() => Promise<void>}
166
+ */
167
+ function createPacer(rps) {
168
+ if (!rps) return async () => {};
169
+
170
+ const gap = 1000 / rps;
171
+ let nextSlot = 0;
172
+
173
+ return async () => {
174
+ const now = Date.now();
175
+ const slot = Math.max(now, nextSlot);
176
+ nextSlot = slot + gap;
177
+ if (slot > now) await sleep(slot - now);
178
+ };
179
+ }
180
+
81
181
  /**
82
182
  * `DEV_TOKEN` ayarlıyken `devGate` token taşımayan her isteğe 404 döner.
83
183
  * Isıtma kendi sunucusuna istek attığı için token'ı çerez olarak taşımalı;
@@ -100,9 +200,10 @@ function devGateHeader() {
100
200
  * @param {(ok: number, failed: number) => void} [report]
101
201
  * Tur ilerlemesini `prewarmProgress`'e yazar. Tekrar turunda sayaçların
102
202
  * anlamı değiştiği için çağıran taraf kendi formülünü verir.
203
+ * @param {() => Promise<void>} [pace] İstek başına beklenen hız freni.
103
204
  * @returns {Promise<{ ok: number, failed: number, failedPaths: string[] }>}
104
205
  */
105
- async function crawl(origin, paths, concurrency, report = undefined) {
206
+ async function crawl(origin, paths, concurrency, report = undefined, pace = undefined) {
106
207
  const { brand } = getConfig();
107
208
  const cacheHeader = brand.cacheHeader.toLowerCase();
108
209
 
@@ -117,6 +218,8 @@ async function crawl(origin, paths, concurrency, report = undefined) {
117
218
  const target = paths[index];
118
219
  index += 1;
119
220
 
221
+ if (pace) await pace();
222
+
120
223
  const startedAt = Date.now();
121
224
 
122
225
  try {
@@ -191,8 +294,15 @@ export async function prewarm({ origin, quiet = false, paths: only }) {
191
294
  process.env.NODE_ENV === "development" ? 2 : 4,
192
295
  );
193
296
 
297
+ const rps = num(process.env.PREWARM_RPS, num(getConfig().prewarm?.rps, 0));
298
+ const pace = createPacer(rps);
299
+
194
300
  const all = only?.length ? only : await collectPaths();
195
- const paths = all.slice(0, limit);
301
+ // Elle verilen liste budanmaz ve sıralanmaz: çağıran tam olarak neyi
302
+ // istediğini biliyor (dev panelindeki "tekrar dene" bunu kullanır).
303
+ const paths = only?.length
304
+ ? all
305
+ : selectPrewarmPaths(all, limit, getConfig().prewarm?.rotate !== false);
196
306
 
197
307
  Object.assign(prewarmProgress, {
198
308
  active: true,
@@ -211,7 +321,13 @@ export async function prewarm({ origin, quiet = false, paths: only }) {
211
321
  try {
212
322
  /** @type {string[]} */
213
323
  let failedPaths;
214
- ({ ok, failed, failedPaths } = await crawl(origin, paths, concurrency));
324
+ ({ ok, failed, failedPaths } = await crawl(
325
+ origin,
326
+ paths,
327
+ concurrency,
328
+ undefined,
329
+ pace,
330
+ ));
215
331
 
216
332
  // Hatalar çoğunlukla upstream rate limit'i (429): ilk tur yüzlerce sayfayı
217
333
  // aynı anda çekerken API'yi zorluyor. Tek seri tekrar turu bu sayfaların
@@ -219,11 +335,23 @@ export async function prewarm({ origin, quiet = false, paths: only }) {
219
335
  if (failedPaths.length) {
220
336
  const firstOk = ok;
221
337
  const firstFailed = failed;
222
- const retry = await crawl(origin, failedPaths, 1, (retriedOk) => {
223
- // Tekrar turunda her başarı bir hatayı başarıya çevirir.
224
- prewarmProgress.ok = firstOk + retriedOk;
225
- prewarmProgress.failed = firstFailed - retriedOk;
226
- });
338
+
339
+ // Rate limit pencereleri saniye mertebesinde; hemen tekrar denemek aynı
340
+ // 429'u almak demek.
341
+ const retryDelay = setting("PREWARM_RETRY_DELAY_MS", "retryDelayMs", 2000);
342
+ await sleep(retryDelay);
343
+
344
+ const retry = await crawl(
345
+ origin,
346
+ failedPaths,
347
+ 1,
348
+ (retriedOk) => {
349
+ // Tekrar turunda her başarı bir hatayı başarıya çevirir.
350
+ prewarmProgress.ok = firstOk + retriedOk;
351
+ prewarmProgress.failed = firstFailed - retriedOk;
352
+ },
353
+ pace,
354
+ );
227
355
  recovered = retry.ok;
228
356
  ok += retry.ok;
229
357
  failed -= retry.ok;
@@ -236,11 +364,15 @@ export async function prewarm({ origin, quiet = false, paths: only }) {
236
364
 
237
365
  if (!quiet && paths.length) {
238
366
  const skipped = all.length - paths.length;
367
+ // Rotasyon açıkken sınırın dışında kalan yollar kaybolmuyor, bir sonraki
368
+ // tura kalıyor; log bunu ayırt etmeli, yoksa "400 yol atlandı" satırı
369
+ // hatalı bir kurulum sanılıyor.
370
+ const rotate = !only?.length && getConfig().prewarm?.rotate !== false;
239
371
  console.log(
240
- `[prewarm] ${ok}/${paths.length} sayfa ısıtıldı` +
241
- `${failed ? `, ${failed} hata` : ""}` +
242
- `${recovered ? `, ${recovered} sayfa tekrar turunda kurtarıldı` : ""}` +
243
- `${skipped > 0 ? `, ${skipped} sayfa limit dışı` : ""}` +
372
+ `[prewarm] warmed ${ok}/${paths.length} pages` +
373
+ `${failed ? `, ${failed} failed` : ""}` +
374
+ `${recovered ? `, ${recovered} recovered on the retry pass` : ""}` +
375
+ `${skipped > 0 ? `, ${skipped} ${rotate ? "deferred to the next pass" : "over the limit"}` : ""}` +
244
376
  ` (${(elapsed / 1000).toFixed(1)}s)`,
245
377
  );
246
378
  }
@@ -264,20 +396,33 @@ export function startPrewarm({ port }) {
264
396
 
265
397
  const isDev = process.env.NODE_ENV === "development";
266
398
  const origin = `http://127.0.0.1:${port}`;
267
- const run = () =>
268
- prewarm({ origin }).catch((error) => {
269
- console.error("[prewarm] başarısız", error);
270
- });
399
+
400
+ // Hız frenli bir tur `intervalSeconds`'tan uzun sürebilir; üst üste binen
401
+ // turlar `prewarmProgress`'i bozar ve upstream'e iki kat yük bindirir.
402
+ let running = false;
403
+ const run = async () => {
404
+ if (running) return;
405
+ running = true;
406
+ try {
407
+ await prewarm({ origin });
408
+ } catch (error) {
409
+ console.error("[prewarm] failed", error);
410
+ } finally {
411
+ running = false;
412
+ }
413
+ };
271
414
 
272
415
  // Isıtma ilk isteklerle yarışmasın diye gecikmeyle başlar. Dev'de gecikme
273
416
  // daha uzun: dosya kaydı süreci yeniden başlattığı için zamanlayıcı da
274
417
  // ölür; yalnızca sunucu bir süre sakin kalınca ısınır.
275
418
  const delay = setting("PREWARM_DELAY_MS", "delayMs", isDev ? 3000 : 500);
276
- setTimeout(run, delay).unref();
419
+ setTimeout(() => void run(), delay).unref();
277
420
 
278
421
  // Girdiler `revalidate` ile yaşlanır; stale-while-revalidate sayesinde
279
422
  // ziyaretçi beklemez. Periyodik tur, hiç ziyaret edilmeyen sayfaları da
280
423
  // sıcak tutmak isteyen kurulumlar için opsiyoneldir.
424
+ // `rotate` ile birlikte bu ayar "damla damla ısıtma"ya dönüşür: her tur
425
+ // kuyruğun bir dilimini alır, yeterli tur sonunda liste baştan sona ısınır.
281
426
  const interval = setting("PREWARM_INTERVAL_SECONDS", "intervalSeconds", 0);
282
- if (interval > 0) setInterval(run, interval * 1000).unref();
427
+ if (interval > 0) setInterval(() => void run(), interval * 1000).unref();
283
428
  }
@@ -21,6 +21,12 @@ import { matchPattern } from "../config/pattern.js";
21
21
  import { encodeText, negotiateEncoding } from "./middleware/compression.js";
22
22
  import { navigationHints, preconnectHints } from "./head-hints.js";
23
23
  import { withRequestCache } from "../http/request-cache.js";
24
+ import {
25
+ createRequestContext,
26
+ getRequestContext,
27
+ guardRequest,
28
+ withRequestContext,
29
+ } from "../http/request-context.js";
24
30
  import { getUpstreamFailures, withUpstreamTracking } from "./upstream-tracking.js";
25
31
  import { isNotFoundError, isRedirectError } from "../http/control-flow.js";
26
32
  import { renderHeadMeta } from "./metadata.js";
@@ -153,50 +159,129 @@ export async function renderPage(page) {
153
159
  );
154
160
  }
155
161
 
162
+ /**
163
+ * Kişiye özel yanıtın cache direktifi. Dinamik (cache'lenmeyen) her sayfa da
164
+ * bunu alır: bir yanıt hiçbir direktif taşımadığında HTTP onu "sezgisel olarak
165
+ * cache'lenebilir" sayar ve araya giren bir proxy ya da tarayıcının geri
166
+ * tuşu kullanıcıya özel HTML'i saklayabilir.
167
+ */
168
+ const PRIVATE_CACHE = "private, no-store";
169
+
156
170
  /**
157
171
  * Controller'ı çalıştırıp yanıtı yazar; notFound/redirect kontrol akışını,
158
172
  * HTML cache'ini ve hata yönetimini üstlenir.
159
173
  *
174
+ * `private: true` kişiye özel sayfaları public cache yolundan tamamen ayırır:
175
+ * HTML cache devre dışı kalır, config'in `cache.html` deseni bu kararı
176
+ * ezemez, yanıt `no-store` ile ve ETag'siz gider. Dashboard tipi sayfalarda
177
+ * bu bayrak olmadan çalışmak, bir kullanıcının HTML'inin bir başkasına
178
+ * servis edilmesi anlamına gelir.
179
+ *
160
180
  * @param {(ctx: { params: object, query: object, pathname: string,
161
181
  * req: import('express').Request }) => Promise<object>} controller
162
- * @param {{ revalidate?: number }} [options]
182
+ * @param {{ revalidate?: number, private?: boolean }} [options]
163
183
  * @returns {import('express').RequestHandler}
164
184
  */
165
185
  export function route(controller, options = {}) {
186
+ const isPrivate = options.private === true;
187
+
166
188
  return async (req, res, next) => {
189
+ const context = createRequestContext({ private: isPrivate, res });
167
190
  const ctx = {
168
191
  params: req.params ?? {},
169
192
  query: req.query ?? {},
170
193
  pathname: req.path,
171
- req,
194
+ // Cookie/Authorization okunursa çıktı kullanıcıya bağlıdır; cache'e
195
+ // yazılmaması için işaretlenmesi gerekiyor.
196
+ req: guardRequest(req),
172
197
  };
173
198
 
174
- const revalidate = resolveRevalidate(req.path, options.revalidate);
175
- const cacheable = req.method === "GET" && Boolean(revalidate);
199
+ // Private route'ta desen taraması hiç yapılmaz: `cache.html` altındaki
200
+ // geniş bir kural (`/**` gibi) bu sayfayı cache'lenebilir hâle
201
+ // getirmesin. Kilit tek yönlü — route "özel" dediyse config açamaz.
202
+ const revalidate = isPrivate
203
+ ? undefined
204
+ : resolveRevalidate(req.path, options.revalidate);
205
+ const cacheable = !isPrivate && req.method === "GET" && Boolean(revalidate);
176
206
  const cacheKey = `${req.path}?${new URLSearchParams(
177
207
  Object.entries(ctx.query).map(([k, v]) => [k, String(v)]),
178
208
  ).toString()}`;
179
209
 
180
210
  try {
181
- const result = await withHtmlCache(cacheKey, cacheable ? revalidate : 0, () =>
182
- withUpstreamTracking(() => withRequestCache(() => produce(controller, ctx))),
211
+ const result = await withRequestContext(context, () =>
212
+ withHtmlCache(cacheKey, cacheable ? revalidate : 0, () =>
213
+ withUpstreamTracking(() => withRequestCache(() => produce(controller, ctx))),
214
+ ),
183
215
  );
184
216
 
217
+ // Cache'lenebilir bir route kimliğe dokunduysa yanıt yine gider ama
218
+ // saklanmaz (`produce` bunu `storable: false` ile bildirdi) ve public
219
+ // direktif yazılmaz.
220
+ const leaked = cacheable && context.tainted;
221
+ if (leaked && isDev) {
222
+ throw new Error(
223
+ `[render] ${req.path} is a cacheable route but read identity-bound data ` +
224
+ `(${context.taintReasons.join(", ")}). This page must be registered with ` +
225
+ `'route(fn, { private: true })'; otherwise one user's HTML is served to another.`,
226
+ );
227
+ }
228
+
229
+ // Eksik veriyle üretilen çıktı süreç içi önbelleğe yazılmıyor; aynı
230
+ // çıktıya CDN'de `s-maxage` vermek o kararı bir katman yukarıda geri
231
+ // almak olurdu. Geçici bir 429 yüzünden üretilen 503, ters proxy'de
232
+ // dakikalarca yaşamamalı.
233
+ const publicCache = cacheable && !leaked && !result.degraded;
234
+
185
235
  res.status(result.status);
186
236
  res.setHeader("Content-Type", "text/html; charset=utf-8");
187
- if (cacheable) {
237
+
238
+ // Geçici upstream hatası: istemciye ve bota "bu kalıcı değil, sonra gel"
239
+ // demenin standart yolu.
240
+ if (result.retryAfter) {
241
+ res.setHeader("Retry-After", String(result.retryAfter));
242
+ }
243
+
244
+ // Teşhis başlığı `degraded` yanıtta da yazılır: "MISS" görmek, sayfanın
245
+ // önbellek yolundan geçtiğini ama saklanmadığını anlatan tek ipucu.
246
+ if (cacheable && !leaked) {
247
+ res.setHeader(
248
+ getConfig().brand.cacheHeader,
249
+ result.cached ? (result.stale ? "STALE" : "HIT") : "MISS",
250
+ );
251
+ }
252
+
253
+ if (publicCache) {
188
254
  res.setHeader(
189
255
  "Cache-Control",
190
256
  `public, max-age=0, s-maxage=${revalidate}, stale-while-revalidate=60`,
191
257
  );
258
+ } else {
259
+ res.setHeader("Cache-Control", PRIVATE_CACHE);
260
+ // Anahtarında cookie olmayan bir cache'in bu yanıtı paylaşmasını
261
+ // engeller; `no-store`'a uymayan bir katman için ikinci savunma.
262
+ res.setHeader("Vary", "Cookie");
263
+
264
+ if (leaked) {
265
+ console.warn(
266
+ `[render] ${req.path} read identity-bound data (${context.taintReasons.join(", ")}), ` +
267
+ `not cached. The route should be registered with 'private: true'.`,
268
+ );
269
+ }
192
270
  }
193
- res.setHeader(
194
- getConfig().brand.cacheHeader,
195
- result.cached ? (result.stale ? "STALE" : "HIT") : "MISS",
196
- );
197
- await sendHtml(req, res, result.html, result.encoded);
271
+
272
+ // ETag kişiye özel HTML için kullanıcıya özgü bir doğrulayıcıdır ve
273
+ // `no-store` ile birlikte hiçbir işe yaramaz; üretilmesi engellenir.
274
+ await sendHtml(req, res, result.html, result.encoded, { etag: publicCache });
198
275
  } catch (error) {
199
276
  if (isRedirectError(error)) {
277
+ // Oturuma bağlı bir yönlendirme de kişiye özeldir: "giriş yapmalısın"
278
+ // kararının cache'lenmesi, oturum açmış kullanıcıyı da login sayfasına
279
+ // atan türde hatalara yol açıyor.
280
+ if (isPrivate || context.tainted) {
281
+ res.setHeader("Cache-Control", PRIVATE_CACHE);
282
+ res.setHeader("Vary", "Cookie");
283
+ }
284
+
200
285
  res.redirect(error.statusCode, error.location);
201
286
  return;
202
287
  }
@@ -205,6 +290,105 @@ export function route(controller, options = {}) {
205
290
  };
206
291
  }
207
292
 
293
+ /**
294
+ * Layout'suz, asla cache'lenmeyen parça yanıtı.
295
+ *
296
+ * Fragment uçları (tablo sayfası, sekme paneli, canlı tazelenen kart) her
297
+ * projede elle yazılıyor ve `no-store` yazmayı unutmak sessiz bir sızıntıya
298
+ * dönüşüyor. Burada politika sabit: HTML cache'e hiç uğramaz, `no-store` ile
299
+ * ve ETag'siz gider.
300
+ *
301
+ * Hata durumunda tüm sayfa yerine küçük bir hata parçası döner: takas edilen
302
+ * bölge bir hata sayfasının tamamını içine almasın.
303
+ *
304
+ * @param {(ctx: { params: object, query: object, pathname: string,
305
+ * req: import('express').Request }) => Promise<{ view: string, data?: object,
306
+ * status?: number } | string>} controller
307
+ * @returns {import('express').RequestHandler}
308
+ */
309
+ export function fragment(controller) {
310
+ return async (req, res, next) => {
311
+ const context = createRequestContext({ private: true, res });
312
+ const ctx = {
313
+ params: req.params ?? {},
314
+ query: req.query ?? {},
315
+ pathname: req.path,
316
+ req: guardRequest(req),
317
+ };
318
+
319
+ try {
320
+ const result = await withRequestContext(context, () =>
321
+ withRequestCache(async () => {
322
+ const value = await controller(ctx);
323
+ if (typeof value === "string") return { html: value, status: 200 };
324
+
325
+ return {
326
+ html: await renderView(value.view, value.data ?? {}),
327
+ status: value.status ?? 200,
328
+ };
329
+ }),
330
+ );
331
+
332
+ res.status(result.status);
333
+ res.setHeader("Content-Type", "text/html; charset=utf-8");
334
+ res.setHeader("Cache-Control", PRIVATE_CACHE);
335
+ res.setHeader("Vary", "Cookie");
336
+ await sendHtml(req, res, result.html, undefined, { etag: false });
337
+ } catch (error) {
338
+ if (isRedirectError(error)) {
339
+ res.setHeader("Cache-Control", PRIVATE_CACHE);
340
+ res.redirect(error.statusCode, error.location);
341
+ return;
342
+ }
343
+
344
+ if (isNotFoundError(error)) {
345
+ res.status(404).setHeader("Cache-Control", PRIVATE_CACHE);
346
+ res.type("html").send(fragmentError("notFound"));
347
+ return;
348
+ }
349
+
350
+ console.error(`[fragment] ${req.method} ${req.originalUrl}`, error);
351
+
352
+ if (res.headersSent) {
353
+ next(error);
354
+ return;
355
+ }
356
+
357
+ res.status(500).setHeader("Cache-Control", PRIVATE_CACHE);
358
+ res.type("html").send(fragmentError("failed"));
359
+ }
360
+ };
361
+ }
362
+
363
+ /**
364
+ * Ziyaretçiye görünen parça hatası metinleri. Framework'ün kendi logları
365
+ * İngilizce ama bu satırlar ekranda okunuyor, bu yüzden durum sayfalarıyla
366
+ * aynı kuralı izliyorlar: dil `brand.lang`.
367
+ */
368
+ const FRAGMENT_MESSAGES = {
369
+ tr: { notFound: "Bu içerik bulunamadı.", failed: "Bu bölüm yüklenemedi." },
370
+ en: { notFound: "This content was not found.", failed: "This section could not be loaded." },
371
+ };
372
+
373
+ /**
374
+ * Fragment hatası için minimal işaretleme. Şablona bağlı olmaması bilinçli:
375
+ * hata yolu, hatanın kaynağı olabilecek render katmanına geri dönmemeli.
376
+ *
377
+ * @param {"notFound" | "failed"} kind
378
+ * @returns {string}
379
+ */
380
+ function fragmentError(kind) {
381
+ let lang = "en";
382
+ try {
383
+ lang = getConfig().brand.lang ?? "en";
384
+ } catch {
385
+ /* config yüklenmemişse İngilizce kalır */
386
+ }
387
+
388
+ const table = FRAGMENT_MESSAGES[lang.slice(0, 2).toLowerCase()] ?? FRAGMENT_MESSAGES.en;
389
+ return `<div role="alert" data-fragment-error>${html.esc(table[kind])}</div>`;
390
+ }
391
+
208
392
  /**
209
393
  * `jskelet.config.mjs` → `cache().html` route'un kendi `revalidate`'ini ezer.
210
394
  * Sonuç yol başına hatırlanır: her istekte desen taraması yapılmaz.
@@ -253,13 +437,23 @@ function resolveRevalidate(pathname, fallback) {
253
437
  * @param {import('express').Response} res
254
438
  * @param {string} body
255
439
  * @param {Map<string, Buffer>} [encoded]
440
+ * @param {{ etag?: boolean }} [options] `etag: false` → `res.send()` atlanır,
441
+ * böylece Express kullanıcıya özel gövde için doğrulayıcı üretmez.
256
442
  * @returns {Promise<void>}
257
443
  */
258
- async function sendHtml(req, res, body, encoded) {
444
+ async function sendHtml(req, res, body, encoded, options = {}) {
259
445
  const encoding =
260
446
  req.method === "HEAD" ? null : negotiateEncoding(req.headers["accept-encoding"]);
261
447
 
262
448
  if (!encoding || !encoded) {
449
+ if (options.etag === false) {
450
+ // Sıkıştırma middleware'i devreye girerse Content-Length'i kendisi
451
+ // kaldırır; girmezse doğru uzunlukla gider.
452
+ res.setHeader("Content-Length", String(Buffer.byteLength(body)));
453
+ res.end(req.method === "HEAD" ? undefined : body);
454
+ return;
455
+ }
456
+
263
457
  res.send(body);
264
458
  return;
265
459
  }
@@ -279,7 +473,8 @@ async function sendHtml(req, res, body, encoded) {
279
473
  /**
280
474
  * @param {Function} controller
281
475
  * @param {{ pathname: string }} ctx
282
- * @returns {Promise<{ html: string, status: number, degraded?: boolean }>}
476
+ * @returns {Promise<{ html: string, status: number, degraded?: boolean,
477
+ * storable?: boolean, retryAfter?: number }>}
283
478
  */
284
479
  async function produce(controller, ctx) {
285
480
  try {
@@ -289,15 +484,45 @@ async function produce(controller, ctx) {
289
484
  html: rendered,
290
485
  status: page.status ?? 200,
291
486
  degraded: hasUpstreamFailures(ctx.pathname),
487
+ // Kimliğe bağlı çıktı önbelleğe yazılmaz. Karar burada verilmeli:
488
+ // `withHtmlCache` yazma anında controller'ın ne okuduğunu bilemez.
489
+ storable: getRequestContext()?.tainted !== true,
292
490
  };
293
491
  } catch (error) {
294
492
  if (isNotFoundError(error)) {
493
+ // Veri gelmediği için `notFound()` çağrılmış olabilir: geçici bir
494
+ // upstream hatası (429, 5xx, ağ) varken bunu 404 olarak servis etmek iki
495
+ // kere yanlış. Önbelleğe girip TTL boyunca "bu sayfa yok" cevabını
496
+ // sabitler ve arama motoru geçici bir rate limit'i kalıcı 404 sanar.
497
+ // Doğrusu 503: cache'lenmez, `Retry-After` ile gider, sonraki istek
498
+ // gerçek içeriği üretir.
499
+ const transient = transientUpstreamFailures();
500
+ if (transient.length) {
501
+ console.warn(
502
+ `[render] ${ctx.pathname} returned notFound() while upstream is failing ` +
503
+ `(${summarizeFailures(transient)}), serving an uncached 503 instead`,
504
+ );
505
+ return {
506
+ html: await renderStatusPage(503),
507
+ status: 503,
508
+ degraded: true,
509
+ retryAfter: RETRY_AFTER_SECONDS,
510
+ };
511
+ }
512
+
295
513
  return { html: await renderNotFound(), status: 404 };
296
514
  }
297
515
  throw error;
298
516
  }
299
517
  }
300
518
 
519
+ /**
520
+ * Geçici bir upstream hatası yüzünden üretilemeyen sayfanın `Retry-After`
521
+ * değeri. Kısa tutuluyor: ziyaretçi de bot da birkaç saniye sonra gerçek
522
+ * içeriği bulabilsin.
523
+ */
524
+ const RETRY_AFTER_SECONDS = 30;
525
+
301
526
  /** Ağ hatası (0) ve geçici olduğu varsayılan durumlar. */
302
527
  const TRANSIENT_STATUSES = new Set([0, 408, 425, 429]);
303
528
 
@@ -323,28 +548,39 @@ function hasUpstreamFailures(pathname) {
323
548
  const failures = getUpstreamFailures();
324
549
  if (!failures.length) return false;
325
550
 
326
- /** @param {typeof failures} list */
327
- const summarize = (list) =>
328
- list.map((failure) => `${failure.status} ${failure.path}`).join(", ");
329
-
330
551
  const transient = failures.filter((failure) => isTransient(failure.status));
331
552
  const permanent = failures.filter((failure) => !isTransient(failure.status));
332
553
 
333
554
  if (permanent.length) {
334
555
  console.warn(
335
- `[render] ${pathname} eksik veriyle üretildi, upstream kalıcı hata veriyor (${summarize(permanent)})`,
556
+ `[render] ${pathname} was produced with missing data, upstream is failing permanently (${summarizeFailures(permanent)})`,
336
557
  );
337
558
  }
338
559
 
339
560
  if (!transient.length) return false;
340
561
 
341
562
  console.warn(
342
- `[render] ${pathname} eksik veriyle üretildi, önbelleğe alınmıyor (${summarize(transient)})`,
563
+ `[render] ${pathname} was produced with missing data, not caching it (${summarizeFailures(transient)})`,
343
564
  );
344
565
 
345
566
  return true;
346
567
  }
347
568
 
569
+ /**
570
+ * @returns {import('./upstream-tracking.js').UpstreamFailure[]}
571
+ */
572
+ function transientUpstreamFailures() {
573
+ return getUpstreamFailures().filter((failure) => isTransient(failure.status));
574
+ }
575
+
576
+ /**
577
+ * @param {import('./upstream-tracking.js').UpstreamFailure[]} failures
578
+ * @returns {string}
579
+ */
580
+ function summarizeFailures(failures) {
581
+ return failures.map((failure) => `${failure.status} ${failure.path}`).join(", ");
582
+ }
583
+
348
584
  /**
349
585
  * 404 sayfası. `renderStatusPage(404)` için kısayol; route dosyalarında en sık
350
586
  * ihtiyaç duyulan durum bu olduğu için ayrı bir ad taşımaya devam ediyor.