jskelet 0.6.2 → 0.6.4

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 (43) hide show
  1. package/CHANGELOG.md +32 -0
  2. package/README.md +2 -0
  3. package/docs/02-mimari.md +1 -0
  4. package/docs/04-render-ve-sablonlar.md +9 -3
  5. package/docs/06-cache.md +53 -9
  6. package/docs/07-yapilandirma.md +1208 -1191
  7. package/docs/08-build.md +2 -1
  8. package/docs/10-dagitim.md +16 -6
  9. package/docs/12-panel-ve-oturum.md +2 -1
  10. package/docs/en/02-architecture.md +2 -1
  11. package/docs/en/04-rendering.md +9 -3
  12. package/docs/en/06-caching.md +54 -9
  13. package/docs/en/07-configuration.md +24 -9
  14. package/docs/en/08-build.md +2 -1
  15. package/docs/en/10-deployment.md +17 -6
  16. package/docs/en/12-dashboards-and-sessions.md +2 -1
  17. package/package.json +1 -1
  18. package/src/config/defaults.js +541 -518
  19. package/src/config/index.js +1500 -1456
  20. package/src/init.mjs +2 -0
  21. package/src/server/cache-blob.js +70 -0
  22. package/src/server/cache-control.js +45 -0
  23. package/src/server/data-cache.js +118 -27
  24. package/src/server/disk-cache.js +233 -0
  25. package/src/server/html-cache.js +90 -16
  26. package/src/server/image-optimizer.js +95 -2
  27. package/src/server/logs/file-sink.js +159 -32
  28. package/src/server/logs/pipeline.js +10 -3
  29. package/src/server/middleware/static-precompressed.js +31 -10
  30. package/src/server/og-image.js +17 -4
  31. package/src/server/prewarm.js +25 -1
  32. package/src/server/redis.js +31 -12
  33. package/src/server/render.js +910 -910
  34. package/types/config/defaults.d.ts +21 -1
  35. package/types/config/index.d.ts +14 -0
  36. package/types/server/cache-blob.d.ts +13 -0
  37. package/types/server/cache-control.d.ts +28 -0
  38. package/types/server/data-cache.d.ts +9 -0
  39. package/types/server/disk-cache.d.ts +36 -0
  40. package/types/server/html-cache.d.ts +26 -3
  41. package/types/server/logs/file-sink.d.ts +16 -5
  42. package/types/server/og-image.d.ts +5 -0
  43. package/types/server/redis.d.ts +2 -1
@@ -1,910 +1,910 @@
1
- /**
2
- * Render katmanı + HTML TTL cache (ISR ikamesi).
3
- * Varsayılan şablon yolu derlenmiş `.jsk`; EJS opsiyonel legacy peer.
4
- *
5
- * Controller sözleşmesi:
6
- * async (ctx) => { view, data?, metadata?, status?, revalidate?, head?,
7
- * bodyClass?, entries?, styles? }
8
- * `ctx` → { params, query, pathname, req }
9
- *
10
- * `route()` üç kapsamı belirli bir sırayla iç içe kurar:
11
- * withHtmlCache( withUpstreamTracking( withRequestCache( controller ) ) )
12
- * Sıra önemli — istek içi cache en içte olmalı ki aynı render'daki iki
13
- * çağrı tek upstream isteğine düşsün; upstream takibi HTML cache'in içinde
14
- * olmalı ki eksik veriyle üretilen çıktı önbelleğe yazılmasın.
15
- */
16
- import path from "node:path";
17
- import process from "node:process";
18
- import fs from "node:fs";
19
- import { pathToFileURL } from "node:url";
20
- import { noteHtmlCacheGrowth, withHtmlCache } from "./html-cache.js";
21
- import { buildVaryPrefix } from "./cache-vary.js";
22
- import { getConfig, hook, FRAMEWORK_ROOT } from "../config/index.js";
23
- import { matchPattern } from "../config/pattern.js";
24
- import { encodeText, negotiateEncoding } from "./middleware/compression.js";
25
- import { navigationHints, preconnectHints } from "./head-hints.js";
26
- import { withRequestCache } from "../http/request-cache.js";
27
- import {
28
- createRequestContext,
29
- getRequestContext,
30
- guardRequest,
31
- withRequestContext,
32
- } from "../http/request-context.js";
33
- import {
34
- getUpstreamFailures,
35
- isTransientStatus,
36
- withUpstreamTracking,
37
- } from "./upstream-tracking.js";
38
- import { isNotFoundError, isRedirectError } from "../http/control-flow.js";
39
- import { renderHeadMeta } from "./metadata.js";
40
- import { asset, hasAsset } from "./assets.js";
41
- import * as html from "../views/helpers/html.js";
42
- import * as tags from "../views/helpers/tags.js";
43
- import { loadComponents } from "../views/components/loader.js";
44
- import { renderStatusPage } from "./status-page.js";
45
- import { suppressForPrewarm, noteVisitWarm } from "./prewarm.js";
46
- import {
47
- ensureTemplatesCompiled,
48
- getComponentDirs,
49
- getViewRoots,
50
- } from "../compile/index.js";
51
- import { renderEjsFile } from "./ejs-adapter.js";
52
-
53
- const isDev = process.env.NODE_ENV === "development";
54
-
55
- /**
56
- * Şablonlara otomatik geçen yardımcılar ve (legacy) EJS ayarları. Bileşen
57
- * taraması dosya sistemine dokunduğu için bir kez yapılır; config yüklenmeden
58
- * hesaplanamaz, bu yüzden ilk render'da kurulur.
59
- *
60
- * @type {{ helpers: Record<string, unknown>, options: Record<string, unknown>,
61
- * viewsDir: string, viewRoots: string[], layout: string,
62
- * compiled: Map<string, (data: object, helpers: object) => string>,
63
- * layoutRender: ((data: object, helpers: object) => string) | null
64
- * } | null}
65
- */
66
- let engine = null;
67
-
68
- /**
69
- * @returns {Promise<NonNullable<typeof engine>>}
70
- */
71
- async function getEngine() {
72
- if (engine) return engine;
73
-
74
- const config = getConfig();
75
- const viewsDir = config.dirs.views;
76
- const viewRoots = getViewRoots(config);
77
-
78
- await ensureTemplatesCompiled(config);
79
-
80
- /** @type {Record<string, unknown>} */
81
- const helpers = {
82
- ...html,
83
- ...tags,
84
- ...(await loadComponents(getComponentDirs(config))),
85
- asset,
86
- hasAsset,
87
- // Yerleşik PascalCase etiketler (.jsk bileşen sözdizimi).
88
- Link: (props) => tags.link(props),
89
- Image: (props) => tags.image(props),
90
- Icon: (props) => tags.icon(props),
91
- CsrfField: () => tags.csrfField(),
92
- PreloadImage: (props) => tags.preloadImage(props),
93
- Stylesheets: (props) => tags.stylesheets(props),
94
- BodyScripts: (props) => tags.bodyScripts(props),
95
- JsonLd: (props) => tags.jsonLd(props),
96
- };
97
-
98
- // camelCase JS bileşenlerini PascalCase etiket adıyla da erişilebilir yap.
99
- for (const [name, value] of Object.entries(helpers)) {
100
- if (typeof value !== "function") continue;
101
- if (/^[A-Z]/.test(name)) continue;
102
- const pascal = name.charAt(0).toUpperCase() + name.slice(1);
103
- if (!(pascal in helpers)) helpers[pascal] = value;
104
- }
105
-
106
- const { compiled, layoutRender: appLayoutRender } = await loadCompiledTemplates(
107
- config,
108
- helpers,
109
- );
110
-
111
- // Yalnızca framework varsayılan layout'una düşüldüğünde hazır modülü kullan;
112
- // uygulamanın kendi layout.jsk'si derlenmemişse sessizce framework'e kayma.
113
- let layoutRender = appLayoutRender;
114
- if (!layoutRender) {
115
- const fwLayout = path.join(FRAMEWORK_ROOT, "src", "templates", "layout.jsk");
116
- if (path.resolve(config.layout) === path.resolve(fwLayout)) {
117
- const fw = await import("../templates/layout.render.js");
118
- if (typeof fw.render === "function") layoutRender = fw.render;
119
- }
120
- }
121
-
122
- engine = {
123
- viewsDir,
124
- viewRoots,
125
- layout: config.layout,
126
- helpers,
127
- compiled,
128
- layoutRender,
129
- options: {
130
- // `include('partials/header')` gibi çağrılar views kökünden çözülür.
131
- root: viewsDir,
132
- views: viewRoots.length ? viewRoots : [viewsDir],
133
- cache: !isDev,
134
- rmWhitespace: true,
135
- async: true,
136
- },
137
- };
138
-
139
- return engine;
140
- }
141
-
142
- /**
143
- * Derlenmiş `.jsk` modüllerini yükler. İstek anında parse/compile yok.
144
- *
145
- * @param {import('../config/index.js').ResolvedConfig} config
146
- * @param {Record<string, unknown>} helpers
147
- * @returns {Promise<{ compiled: Map<string, Function>, layoutRender: Function | null }>}
148
- */
149
- async function loadCompiledTemplates(config, helpers) {
150
- /** @type {Map<string, (data: object, helpers: object) => string>} */
151
- const compiled = new Map();
152
- const templatesDir = path.join(config.dirs.generated, "templates");
153
- const manifestPath = path.join(templatesDir, "manifest.json");
154
-
155
- if (!fs.existsSync(manifestPath)) {
156
- return { compiled, layoutRender: null };
157
- }
158
-
159
- /** @type {Record<string, string>} */
160
- const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
161
- const bust = isDev ? `?t=${Date.now()}` : "";
162
-
163
- for (const [viewId, rel] of Object.entries(manifest)) {
164
- const file = path.join(templatesDir, rel);
165
- if (!fs.existsSync(file)) continue;
166
- const mod = await import(pathToFileURL(file).href + bust);
167
- if (typeof mod.render !== "function") continue;
168
- compiled.set(viewId, mod.render);
169
-
170
- // `components/*.jsk` → helpers.PascalName
171
- if (viewId.startsWith("components/")) {
172
- const base = viewId.slice("components/".length).split("/").pop() ?? "";
173
- const pascal = base
174
- .split(/[-_/]+/)
175
- .filter(Boolean)
176
- .map((p) => p.charAt(0).toUpperCase() + p.slice(1))
177
- .join("");
178
- if (pascal) {
179
- helpers[pascal] = (props) => mod.render(props ?? {}, helpers);
180
- }
181
- }
182
- }
183
-
184
- const layoutRender = compiled.get("layout") ?? null;
185
- return { compiled, layoutRender };
186
- }
187
-
188
- /**
189
- * View id için kaynak `.ejs` dosyasını çoklu kökte ara.
190
- * @param {string[]} viewRoots
191
- * @param {string} view
192
- * @returns {string | null}
193
- */
194
- function findEjsView(viewRoots, view) {
195
- for (const root of viewRoots) {
196
- const file = path.join(root, `${view}.ejs`);
197
- if (fs.existsSync(file)) return file;
198
- }
199
- return null;
200
- }
201
-
202
- /**
203
- * Uygulama geliştirirken bileşen dosyaları değişince kayıt yenilenmeli.
204
- * Dev sunucusu süreci yeniden başlattığı için normalde gerekmez; gömülü
205
- * kullanımlar (test, script) için dışa açık.
206
- *
207
- * @returns {void}
208
- */
209
- export function resetRenderEngine() {
210
- engine = null;
211
- }
212
-
213
- /**
214
- * Layout kullanmadan tek bir şablon render eder. Fragment/partial uçları
215
- * ve e-posta şablonları bunu kullanır.
216
- *
217
- * Öncelik: derlenmiş `.jsk` → `.ejs`. İstek anında şablon derlenmez.
218
- *
219
- * @param {string} view `views/` altındaki yol, uzantısız (örn. "pages/home")
220
- * @param {object} [data]
221
- * @returns {Promise<string>}
222
- */
223
- export async function renderView(view, data = {}) {
224
- const { helpers, options, compiled, viewRoots, viewsDir } = await getEngine();
225
- const renderFn = compiled.get(view);
226
- if (renderFn) {
227
- return renderFn({ ...data }, helpers);
228
- }
229
-
230
- const file =
231
- findEjsView(viewRoots.length ? viewRoots : [viewsDir], view) ??
232
- path.join(viewsDir, `${view}.ejs`);
233
- return renderEjsFile(file, { ...helpers, ...data }, options);
234
- }
235
-
236
- /**
237
- * Sayfayı layout içinde render eder.
238
- *
239
- * @param {{ view: string, data?: object, metadata?: object, head?: string,
240
- * bodyClass?: string, entries?: string[], styles?: string[],
241
- * pathname?: string }} page
242
- * @returns {Promise<string>}
243
- */
244
- export async function renderPage(page) {
245
- const { helpers, options, layout, layoutRender } = await getEngine();
246
- const config = getConfig();
247
-
248
- const metadata = {
249
- ...(await hook("metadata", {}, page)),
250
- ...(page.metadata ?? {}),
251
- };
252
-
253
- // Layout bağlamı ve gövde paralel üretilir: navigasyon çoğu projede
254
- // upstream'den geliyor ve gövde render'ıyla sırayla beklemek her sayfaya
255
- // gereksiz gecikme ekliyor.
256
- const [body, context] = await Promise.all([
257
- renderView(page.view, { ...(page.data ?? {}), metadata }),
258
- hook("layoutContext", {}, { pathname: page.pathname ?? "", metadata }),
259
- ]);
260
-
261
- const locals = {
262
- ...helpers,
263
- ...context,
264
- metadata,
265
- // Boş varsayılan bilinçli: "/" yazmak her sayfayı ana sayfa sanıp
266
- // logoyu <h1> olarak bastıran türde hatalara yol açıyor.
267
- pathname: page.pathname ?? "",
268
- lang: context.lang ?? config.brand.lang ?? "en",
269
- headMeta: renderHeadMeta(metadata),
270
- structuredData: context.structuredData ?? [],
271
- // Preconnect her sayfada aynı; LCP preload'ını sayfa kendisi ekler.
272
- // Gezinme ipuçları preconnect'ten sonra: spekülasyon bir sonraki sayfayı
273
- // ilgilendiriyor, bu sayfanın LCP'sinin önüne geçmemeli.
274
- extraHead:
275
- preconnectHints() +
276
- navigationHints() +
277
- (page.head ?? "") +
278
- (context.extraHead ?? ""),
279
- bodyClass: page.bodyClass ?? context.bodyClass ?? "",
280
- entries: page.entries ?? [],
281
- styles: page.styles ?? [],
282
- devtools: isDev,
283
- devBasePath: config.brand.devBasePath,
284
- body,
285
- };
286
-
287
- if (layoutRender) {
288
- return layoutRender(locals, helpers);
289
- }
290
-
291
- if (layout.endsWith(".jsk")) {
292
- // Kaynak var ama derlenmemiş — ensureTemplates sonrası olmamalı.
293
- throw new Error(
294
- `Layout ${path.relative(config.root, layout)} is not compiled — run jskelet build`,
295
- );
296
- }
297
-
298
- return renderEjsFile(layout, locals, options);
299
- }
300
-
301
- /**
302
- * Kişiye özel yanıtın cache direktifi. Dinamik (cache'lenmeyen) her sayfa da
303
- * bunu alır: bir yanıt hiçbir direktif taşımadığında HTTP onu "sezgisel olarak
304
- * cache'lenebilir" sayar ve araya giren bir proxy ya da tarayıcının geri
305
- * tuşu kullanıcıya özel HTML'i saklayabilir.
306
- */
307
- const PRIVATE_CACHE = "private, no-store";
308
-
309
- /**
310
- * Controller'ı çalıştırıp yanıtı yazar; notFound/redirect kontrol akışını,
311
- * HTML cache'ini ve hata yönetimini üstlenir.
312
- *
313
- * `private: true` kişiye özel sayfaları public cache yolundan tamamen ayırır:
314
- * HTML cache devre dışı kalır, config'in `cache.html` deseni bu kararı
315
- * ezemez, yanıt `no-store` ile ve ETag'siz gider. Dashboard tipi sayfalarda
316
- * bu bayrak olmadan çalışmak, bir kullanıcının HTML'inin bir başkasına
317
- * servis edilmesi anlamına gelir.
318
- *
319
- * @param {(ctx: { params: object, query: object, pathname: string,
320
- * req: import('express').Request }) => Promise<object>} controller
321
- * @param {{ revalidate?: number, private?: boolean }} [options]
322
- * @returns {import('express').RequestHandler}
323
- */
324
- export function route(controller, options = {}) {
325
- const isPrivate = options.private === true;
326
-
327
- return async (req, res, next) => {
328
- const context = createRequestContext({
329
- private: isPrivate,
330
- res,
331
- pathname: req.path,
332
- });
333
- const ctx = {
334
- params: req.params ?? {},
335
- query: req.query ?? {},
336
- pathname: req.path,
337
- // Cookie/Authorization okunursa çıktı kullanıcıya bağlıdır; cache'e
338
- // yazılmaması için işaretlenmesi gerekiyor.
339
- req: guardRequest(req),
340
- };
341
-
342
- // Private route'ta desen taraması hiç yapılmaz: `cache.html` altındaki
343
- // geniş bir kural (`/**` gibi) bu sayfayı cache'lenebilir hâle
344
- // getirmesin. Kilit tek yönlü — route "özel" dediyse config açamaz.
345
- const revalidate = isPrivate
346
- ? undefined
347
- : resolveRevalidate(req.path, options.revalidate);
348
- // Anahtar `null` ise query bu yol için cache'lenebilir değil: sayfa
349
- // dinamik davranır. Anahtar yine de gerekiyor (hata sayfası ölçümü,
350
- // teşhis) ama TTL sıfırlanıp cache yolu kapatılır.
351
- const key = buildCacheKey(req.path, ctx.query, req);
352
- const cacheable =
353
- !isPrivate && req.method === "GET" && Boolean(revalidate) && key !== null;
354
- const cacheKey =
355
- key ??
356
- `${buildVaryPrefix(req)}${req.path}?${new URLSearchParams(
357
- Object.entries(ctx.query).map(([k, v]) => [k, String(v)]),
358
- ).toString()}`;
359
-
360
- try {
361
- const result = await withRequestContext(context, () =>
362
- withHtmlCache(cacheKey, cacheable ? revalidate : 0, () =>
363
- withUpstreamTracking(() => withRequestCache(() => produce(controller, ctx))),
364
- ),
365
- );
366
-
367
- // Cache'lenebilir bir route kimliğe dokunduysa yanıt yine gider ama
368
- // saklanmaz (`produce` bunu `storable: false` ile bildirdi) ve public
369
- // direktif yazılmaz.
370
- const leaked = cacheable && context.tainted;
371
- if (leaked && isDev) {
372
- throw new Error(
373
- `[render] ${req.path} is a cacheable route but read identity-bound data ` +
374
- `(${context.taintReasons.join(", ")}). This page must be registered with ` +
375
- `'route(fn, { private: true })'; otherwise one user's HTML is served to another.`,
376
- );
377
- }
378
-
379
- // Eksik veriyle üretilen çıktı süreç içi önbelleğe yazılmıyor; aynı
380
- // çıktıya CDN'de `s-maxage` vermek o kararı bir katman yukarıda geri
381
- // almak olurdu. Geçici bir 429 yüzünden üretilen 503, ters proxy'de
382
- // dakikalarca yaşamamalı.
383
- const publicCache = cacheable && !leaked && !result.degraded;
384
-
385
- res.status(result.status);
386
- res.setHeader("Content-Type", "text/html; charset=utf-8");
387
-
388
- // Geçici upstream hatası: istemciye ve bota "bu kalıcı değil, sonra gel"
389
- // demenin standart yolu.
390
- if (result.retryAfter) {
391
- res.setHeader("Retry-After", String(result.retryAfter));
392
- }
393
-
394
- // Teşhis başlığı `degraded` yanıtta da yazılır: "MISS" görmek, sayfanın
395
- // önbellek yolundan geçtiğini ama saklanmadığını anlatan tek ipucu.
396
- if (cacheable && !leaked) {
397
- res.setHeader(
398
- getConfig().brand.cacheHeader,
399
- result.cached ? (result.stale ? "STALE" : "HIT") : "MISS",
400
- );
401
- }
402
-
403
- if (publicCache) {
404
- res.setHeader(
405
- "Cache-Control",
406
- `public, max-age=0, s-maxage=${revalidate}, stale-while-revalidate=60`,
407
- );
408
- } else {
409
- res.setHeader("Cache-Control", PRIVATE_CACHE);
410
- // Anahtarında cookie olmayan bir cache'in bu yanıtı paylaşmasını
411
- // engeller; `no-store`'a uymayan bir katman için ikinci savunma.
412
- res.setHeader("Vary", "Cookie");
413
-
414
- if (leaked) {
415
- console.warn(
416
- `[render] ${req.path} read identity-bound data (${context.taintReasons.join(", ")}), ` +
417
- `not cached. The route should be registered with 'private: true'.`,
418
- );
419
- }
420
- }
421
-
422
- // ETag kişiye özel HTML için kullanıcıya özgü bir doğrulayıcıdır ve
423
- // `no-store` ile birlikte hiçbir işe yaramaz; üretilmesi engellenir.
424
- await sendHtml(req, res, result.html, result.encoded, { etag: publicCache });
425
-
426
- // onVisit: yanıt gittikten sonra linkleri kuyruğa al — TTFB'yi şişirmez.
427
- // Yalnızca herkese açık, önbelleklenebilir 200 HTML; private / degraded
428
- // sayfadaki linkler kişiye özel veya eksik olabilir.
429
- if (publicCache && result.status === 200 && result.html) {
430
- const pagePath = req.path;
431
- const pageHtml = result.html;
432
- queueMicrotask(() => {
433
- noteVisitWarm(pageHtml, { path: pagePath, req });
434
- });
435
- }
436
- } catch (error) {
437
- if (isRedirectError(error)) {
438
- // Oturuma bağlı bir yönlendirme de kişiye özeldir: "giriş yapmalısın"
439
- // kararının cache'lenmesi, oturum açmış kullanıcıyı da login sayfasına
440
- // atan türde hatalara yol açıyor.
441
- if (isPrivate || context.tainted) {
442
- res.setHeader("Cache-Control", PRIVATE_CACHE);
443
- res.setHeader("Vary", "Cookie");
444
- }
445
-
446
- res.redirect(error.statusCode, error.location);
447
- return;
448
- }
449
- next(error);
450
- }
451
- };
452
- }
453
-
454
- /**
455
- * Layout'suz, asla cache'lenmeyen parça yanıtı.
456
- *
457
- * Fragment uçları (tablo sayfası, sekme paneli, canlı tazelenen kart) her
458
- * projede elle yazılıyor ve `no-store` yazmayı unutmak sessiz bir sızıntıya
459
- * dönüşüyor. Burada politika sabit: HTML cache'e hiç uğramaz, `no-store` ile
460
- * ve ETag'siz gider.
461
- *
462
- * Hata durumunda tüm sayfa yerine küçük bir hata parçası döner: takas edilen
463
- * bölge bir hata sayfasının tamamını içine almasın.
464
- *
465
- * @param {(ctx: { params: object, query: object, pathname: string,
466
- * req: import('express').Request }) => Promise<{ view: string, data?: object,
467
- * status?: number } | string>} controller
468
- * @returns {import('express').RequestHandler}
469
- */
470
- export function fragment(controller) {
471
- return async (req, res, next) => {
472
- const context = createRequestContext({
473
- private: true,
474
- res,
475
- pathname: req.path,
476
- });
477
- const ctx = {
478
- params: req.params ?? {},
479
- query: req.query ?? {},
480
- pathname: req.path,
481
- req: guardRequest(req),
482
- };
483
-
484
- try {
485
- const result = await withRequestContext(context, () =>
486
- withRequestCache(async () => {
487
- const value = await controller(ctx);
488
- if (typeof value === "string") return { html: value, status: 200 };
489
-
490
- return {
491
- html: await renderView(value.view, value.data ?? {}),
492
- status: value.status ?? 200,
493
- };
494
- }),
495
- );
496
-
497
- res.status(result.status);
498
- res.setHeader("Content-Type", "text/html; charset=utf-8");
499
- res.setHeader("Cache-Control", PRIVATE_CACHE);
500
- res.setHeader("Vary", "Cookie");
501
- await sendHtml(req, res, result.html, undefined, { etag: false });
502
- } catch (error) {
503
- if (isRedirectError(error)) {
504
- res.setHeader("Cache-Control", PRIVATE_CACHE);
505
- res.redirect(error.statusCode, error.location);
506
- return;
507
- }
508
-
509
- if (isNotFoundError(error)) {
510
- res.status(404).setHeader("Cache-Control", PRIVATE_CACHE);
511
- res.type("html").send(fragmentError("notFound"));
512
- return;
513
- }
514
-
515
- console.error(`[fragment] ${req.method} ${req.originalUrl}`, error);
516
-
517
- if (res.headersSent) {
518
- next(error);
519
- return;
520
- }
521
-
522
- res.status(500).setHeader("Cache-Control", PRIVATE_CACHE);
523
- res.type("html").send(fragmentError("failed"));
524
- }
525
- };
526
- }
527
-
528
- /**
529
- * Ziyaretçiye görünen parça hatası metinleri. Framework'ün kendi logları
530
- * İngilizce ama bu satırlar ekranda okunuyor, bu yüzden durum sayfalarıyla
531
- * aynı kuralı izliyorlar: dil `brand.lang`.
532
- */
533
- const FRAGMENT_MESSAGES = {
534
- tr: { notFound: "Bu içerik bulunamadı.", failed: "Bu bölüm yüklenemedi." },
535
- en: { notFound: "This content was not found.", failed: "This section could not be loaded." },
536
- };
537
-
538
- /**
539
- * Fragment hatası için minimal işaretleme. Şablona bağlı olmaması bilinçli:
540
- * hata yolu, hatanın kaynağı olabilecek render katmanına geri dönmemeli.
541
- *
542
- * @param {"notFound" | "failed"} kind
543
- * @returns {string}
544
- */
545
- function fragmentError(kind) {
546
- let lang = "en";
547
- try {
548
- lang = getConfig().brand.lang ?? "en";
549
- } catch {
550
- /* config yüklenmemişse İngilizce kalır */
551
- }
552
-
553
- const table = FRAGMENT_MESSAGES[lang.slice(0, 2).toLowerCase()] ?? FRAGMENT_MESSAGES.en;
554
- return `<div role="alert" data-fragment-error>${html.esc(table[kind])}</div>`;
555
- }
556
-
557
- /**
558
- * `jskelet.config.mjs` → `cache().html` route'un kendi `revalidate`'ini ezer.
559
- * Sonuç yol başına hatırlanır: her istekte desen taraması yapılmaz.
560
- *
561
- * @type {Map<string, number | undefined>}
562
- */
563
- const revalidateByPath = new Map();
564
-
565
- /**
566
- * Yakalayıcı bir route'ta (`/:slug`) her benzersiz yol burada kalıcı bir girdi
567
- * bırakıyor; sınır olmadan bu, uzun ömürlü süreçte bellek sızıntısına dönüşür.
568
- * Desen taraması ucuz olduğu için en eski girdileri atmak güvenli.
569
- */
570
- const REVALIDATE_CACHE_MAX = 2000;
571
-
572
- /**
573
- * Aynı gerekçeyle (yakalayıcı route'ta sınırsız büyüme) query kuralı da yol
574
- * başına hatırlanır.
575
- *
576
- * @type {Map<string, true | string[] | null>}
577
- */
578
- const queryPolicyByPath = new Map();
579
-
580
- /**
581
- * @param {string} pathname
582
- * @returns {true | string[] | null} `null`: bu yol için hiçbir parametreye
583
- * izin verilmiyor.
584
- */
585
- function resolveQueryPolicy(pathname) {
586
- if (queryPolicyByPath.has(pathname)) {
587
- return queryPolicyByPath.get(pathname) ?? null;
588
- }
589
-
590
- const rules = getConfig().cacheQuery;
591
- const match = rules.find((rule) => matchPattern(rule.pattern, pathname));
592
-
593
- if (queryPolicyByPath.size >= REVALIDATE_CACHE_MAX) {
594
- const oldest = queryPolicyByPath.keys().next().value;
595
- if (oldest !== undefined) queryPolicyByPath.delete(oldest);
596
- }
597
-
598
- const policy = match ? match.allow : null;
599
- queryPolicyByPath.set(pathname, policy);
600
- return policy;
601
- }
602
-
603
- /**
604
- * HTML cache anahtarı, ya da query bu yol için cache'lenebilir değilse `null`.
605
- *
606
- * Biçim: `${varyPrefix}${pathname}?${izin verilen query}`.
607
- * Vary (`cache().vary`) query allowlist'ten bağımsız; host/locale sitelerinde
608
- * ilk host'un HTML'inin diğerine servis edilmesini engeller.
609
- *
610
- * Varsayılan olarak query parametresi taşıyan istek dinamiktir: `cache().query`
611
- * altında eşleşen bir kural olmadıkça cache'e hiç girmez. Aksi hâlde bir yolun
612
- * bütün `?utm_source=…` varyantları ayrı girdi olur ve LRU'daki gerçek
613
- * sayfaları dışarı atar.
614
- *
615
- * İzin verilen parametreler **sıralı** yazılır: `?a=1&b=2` ile `?b=2&a=1` aynı
616
- * sayfa olduğu için aynı anahtarı almalı.
617
- *
618
- * @param {string} pathname
619
- * @param {Record<string, unknown>} query
620
- * @param {{ headers?: Record<string, unknown>, get?: (name: string) => string | undefined }} [req]
621
- * @returns {string | null}
622
- */
623
- function buildCacheKey(pathname, query, req) {
624
- const vary = buildVaryPrefix(req);
625
- const entries = Object.entries(query);
626
- if (!entries.length) return `${vary}${pathname}?`;
627
-
628
- const policy = resolveQueryPolicy(pathname);
629
- if (policy === null) return null;
630
-
631
- const kept =
632
- policy === true ? entries : entries.filter(([name]) => policy.includes(name));
633
-
634
- // İzin listesi dışındaki parametreler anahtara girmez: sayfa cache'lenir ve
635
- // bütün kampanya varyantları tek kopyayı paylaşır.
636
- const params = new URLSearchParams(
637
- kept
638
- .map(/** @returns {[string, string]} */ ([name, value]) => [name, String(value)])
639
- .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)),
640
- );
641
-
642
- return `${vary}${pathname}?${params.toString()}`;
643
- }
644
-
645
- /**
646
- * @param {string} pathname
647
- * @param {number | undefined} fallback
648
- * @returns {number | undefined}
649
- */
650
- function resolveRevalidate(pathname, fallback) {
651
- const rules = getConfig().html;
652
- if (!rules.length) return fallback;
653
-
654
- if (revalidateByPath.has(pathname)) {
655
- return revalidateByPath.get(pathname) ?? fallback;
656
- }
657
-
658
- const match = rules.find((rule) => matchPattern(rule.pattern, pathname));
659
-
660
- if (revalidateByPath.size >= REVALIDATE_CACHE_MAX) {
661
- const oldest = revalidateByPath.keys().next().value;
662
- if (oldest !== undefined) revalidateByPath.delete(oldest);
663
- }
664
-
665
- revalidateByPath.set(pathname, match?.seconds);
666
- return match ? match.seconds : fallback;
667
- }
668
-
669
- /**
670
- * Cache'lenmiş bir sayfa aynı gövdeyi her istekte yeniden sıkıştırmasın diye
671
- * brotli/gzip çıktısı HTML ile birlikte saklanır. `Content-Encoding` burada
672
- * ayarlandığı için sıkıştırma middleware'i devreye girmez.
673
- *
674
- * @param {import('express').Request} req
675
- * @param {import('express').Response} res
676
- * @param {string} body
677
- * @param {Map<string, Buffer>} [encoded]
678
- * @param {{ etag?: boolean }} [options] `etag: false` → `res.send()` atlanır,
679
- * böylece Express kullanıcıya özel gövde için doğrulayıcı üretmez.
680
- * @returns {Promise<void>}
681
- */
682
- async function sendHtml(req, res, body, encoded, options = {}) {
683
- const encoding =
684
- req.method === "HEAD" ? null : negotiateEncoding(req.headers["accept-encoding"]);
685
-
686
- if (!encoding || !encoded) {
687
- if (options.etag === false) {
688
- // Sıkıştırma middleware'i devreye girerse Content-Length'i kendisi
689
- // kaldırır; girmezse doğru uzunlukla gider.
690
- res.setHeader("Content-Length", String(Buffer.byteLength(body)));
691
- res.end(req.method === "HEAD" ? undefined : body);
692
- return;
693
- }
694
-
695
- res.send(body);
696
- return;
697
- }
698
-
699
- let buffer = encoded.get(encoding);
700
- if (!buffer) {
701
- buffer = await encodeText(body, encoding);
702
- encoded.set(encoding, buffer);
703
- // Gövde L1 girdisine sonradan eklenir; bayt bütçesi ham HTML'i aşmasın.
704
- noteHtmlCacheGrowth();
705
- }
706
-
707
- res.setHeader("Content-Encoding", encoding);
708
- res.setHeader("Vary", "Accept-Encoding");
709
- res.setHeader("Content-Length", String(buffer.length));
710
- res.end(buffer);
711
- }
712
-
713
- /**
714
- * @typedef {{ html: string, status: number, degraded?: boolean,
715
- * storable?: boolean, retryAfter?: number }} Produced
716
- */
717
-
718
- /**
719
- * Controller'ı bir kez çalıştırır. `notFound()` fırlatıldığında sonucu
720
- * ayırt edilebilir biçimde döner: çağıran taraf bunun gerçek bir 404 mü,
721
- * yoksa upstream düştüğü için verinin gelmemesi mi olduğuna karar verecek.
722
- *
723
- * @param {Function} controller
724
- * @param {{ pathname: string }} ctx
725
- * @returns {Promise<{ page: Produced } | { notFound: true,
726
- * transient: import('./upstream-tracking.js').UpstreamFailure[] }>}
727
- */
728
- async function attempt(controller, ctx) {
729
- try {
730
- const page = await controller(ctx);
731
- const rendered = await renderPage({ pathname: ctx.pathname, ...page });
732
- return {
733
- page: {
734
- html: rendered,
735
- status: page.status ?? 200,
736
- degraded: hasUpstreamFailures(ctx.pathname),
737
- // Kimliğe bağlı çıktı önbelleğe yazılmaz. Karar burada verilmeli:
738
- // `withHtmlCache` yazma anında controller'ın ne okuduğunu bilemez.
739
- storable: getRequestContext()?.tainted !== true,
740
- },
741
- };
742
- } catch (error) {
743
- if (isNotFoundError(error)) {
744
- return { notFound: true, transient: transientUpstreamFailures() };
745
- }
746
- throw error;
747
- }
748
- }
749
-
750
- /**
751
- * @param {Function} controller
752
- * @param {{ pathname: string }} ctx
753
- * @returns {Promise<Produced>}
754
- */
755
- async function produce(controller, ctx) {
756
- const first = await attempt(controller, ctx);
757
- if ("page" in first) return first.page;
758
- // Deterministik "böyle bir sayfa yok" cevabı: tekrar denemenin anlamı yok.
759
- if (!first.transient.length) return { html: await renderNotFound(), status: 404 };
760
-
761
- // Buraya gelindiyse `notFound()` veri gelmediği için çağrılmış. **Var olan
762
- // bir sayfayı** 404 olarak servis etmek en kötü sonuç: arama motoru geçici
763
- // bir rate limit'i kalıcı bir kayıp sanar. Bu yüzden sayfa yeniden denenir —
764
- // ısıtma günlükleri gösteriyor ki aynı yol saniyeler sonra 200 dönüyor.
765
- const { attempts, delayMs } = transientRetry();
766
- let failures = first.transient;
767
-
768
- for (let round = 1; round <= attempts; round += 1) {
769
- const retryDetail =
770
- `returned notFound() while upstream is failing ` +
771
- `(${summarizeFailures(failures)}), retrying (${round}/${attempts})`;
772
- if (!suppressForPrewarm(retryDetail)) {
773
- console.warn(`[render] ${ctx.pathname} ${retryDetail}`);
774
- }
775
-
776
- // Beklemeden tekrar denemek rate limit'e girmiş bir API'de aynı 429'u
777
- // getirir; kısa bekleme hem pencerenin dönmesine şans verir hem de
778
- // fırtınayı büyütmez.
779
- await sleep(delayMs * round);
780
-
781
- // Her deneme kendi upstream ve istek içi cache bağlamında çalışır: ilk
782
- // turun hataları ikinci turun kararını kirletmesin ve memoize edilmiş
783
- // boş cevaplar tekrar kullanılmasın.
784
- const retried = await withUpstreamTracking(() =>
785
- withRequestCache(() => attempt(controller, ctx)),
786
- );
787
-
788
- if ("page" in retried) return retried.page;
789
- if (!retried.transient.length) {
790
- // Bu kez upstream sağlam cevap verdi ve "yok" dedi: gerçek 404.
791
- return { html: await renderNotFound(), status: 404 };
792
- }
793
-
794
- failures = retried.transient;
795
- }
796
-
797
- // Denemeler tükendi. 404 yerine 503: önbelleğe girmez, `Retry-After` taşır
798
- // ve bir sonraki istek gerçek içeriği üretebilir.
799
- const outageDetail =
800
- `could not be produced, upstream is still failing ` +
801
- `(${summarizeFailures(failures)}), serving an uncached 503 instead of a 404`;
802
- if (!suppressForPrewarm(outageDetail)) {
803
- console.warn(`[render] ${ctx.pathname} ${outageDetail}`);
804
- }
805
-
806
- return {
807
- html: await renderStatusPage(503),
808
- status: 503,
809
- degraded: true,
810
- retryAfter: RETRY_AFTER_SECONDS,
811
- };
812
- }
813
-
814
- /**
815
- * Geçici bir upstream hatası yüzünden üretilemeyen sayfanın `Retry-After`
816
- * değeri. Kısa tutuluyor: ziyaretçi de bot da birkaç saniye sonra gerçek
817
- * içeriği bulabilsin.
818
- */
819
- const RETRY_AFTER_SECONDS = 30;
820
-
821
- /**
822
- * Tekrar denemenin maliyeti upstream'e binen ikinci bir istek turu; bu yüzden
823
- * varsayılan tek deneme ve kısa bekleme. Rate limit fırtınasında toplam yük
824
- * iki katına çıkabilir, ama alternatifi var olan sayfaları 404'e düşürmek.
825
- *
826
- * @returns {{ attempts: number, delayMs: number }}
827
- */
828
- function transientRetry() {
829
- const raw = /** @type {any} */ (getConfig().transientRetry ?? {});
830
- const attempts = Number(raw.attempts);
831
- const delayMs = Number(raw.delayMs);
832
-
833
- return {
834
- attempts: Number.isFinite(attempts) && attempts >= 0 ? Math.floor(attempts) : 1,
835
- delayMs: Number.isFinite(delayMs) && delayMs >= 0 ? delayMs : 300,
836
- };
837
- }
838
-
839
- /** @param {number} ms */
840
- function sleep(ms) {
841
- return new Promise((resolve) => {
842
- setTimeout(resolve, ms).unref?.();
843
- });
844
- }
845
-
846
-
847
- /**
848
- * Render sırasında upstream düştüyse çıktı eksik veri içeriyor demektir.
849
- * Böyle bir HTML önbelleğe yazılmaz: sonraki istek yeniden dener.
850
- *
851
- * Ancak yalnızca *geçici* hatalar için. 400/403/404 gibi deterministik
852
- * cevaplar tekrar denemekle düzelmez; onlar yüzünden önbelleği kapatmak
853
- * sayfayı her ziyarette baştan render etmek olur — içerik yine aynı eksik
854
- * hâliyle döner, ziyaretçi sadece render süresini öder. Bu yüzden kalıcı
855
- * hatalar yalnızca loglanır, önbelleği engellemez.
856
- *
857
- * @param {string} pathname
858
- * @returns {boolean}
859
- */
860
- function hasUpstreamFailures(pathname) {
861
- const failures = getUpstreamFailures();
862
- if (!failures.length) return false;
863
-
864
- const transient = failures.filter((failure) => isTransientStatus(failure.status));
865
- const permanent = failures.filter((failure) => !isTransientStatus(failure.status));
866
-
867
- // Isıtma turu yüzlerce yolu tarıyor; aynı upstream arızası her yol için bir
868
- // satır basınca tur özeti kayboluyor. Turun uyarıları sayılıp sonunda
869
- // toplanır (bkz. `suppressForPrewarm`).
870
- if (permanent.length) {
871
- const detail = `missing data, upstream is failing permanently (${summarizeFailures(permanent)})`;
872
- if (!suppressForPrewarm(detail)) {
873
- console.warn(`[render] ${pathname} was produced with ${detail}`);
874
- }
875
- }
876
-
877
- if (!transient.length) return false;
878
-
879
- const detail = `missing data, not caching it (${summarizeFailures(transient)})`;
880
- if (!suppressForPrewarm(detail)) {
881
- console.warn(`[render] ${pathname} was produced with ${detail}`);
882
- }
883
-
884
- return true;
885
- }
886
-
887
- /**
888
- * @returns {import('./upstream-tracking.js').UpstreamFailure[]}
889
- */
890
- function transientUpstreamFailures() {
891
- return getUpstreamFailures().filter((failure) => isTransientStatus(failure.status));
892
- }
893
-
894
- /**
895
- * @param {import('./upstream-tracking.js').UpstreamFailure[]} failures
896
- * @returns {string}
897
- */
898
- function summarizeFailures(failures) {
899
- return failures.map((failure) => `${failure.status} ${failure.path}`).join(", ");
900
- }
901
-
902
- /**
903
- * 404 sayfası. `renderStatusPage(404)` için kısayol; route dosyalarında en sık
904
- * ihtiyaç duyulan durum bu olduğu için ayrı bir ad taşımaya devam ediyor.
905
- *
906
- * @returns {Promise<string>}
907
- */
908
- export async function renderNotFound() {
909
- return renderStatusPage(404);
910
- }
1
+ /**
2
+ * Render katmanı + HTML TTL cache (ISR ikamesi).
3
+ * Varsayılan şablon yolu derlenmiş `.jsk`; EJS opsiyonel legacy peer.
4
+ *
5
+ * Controller sözleşmesi:
6
+ * async (ctx) => { view, data?, metadata?, status?, revalidate?, head?,
7
+ * bodyClass?, entries?, styles? }
8
+ * `ctx` → { params, query, pathname, req }
9
+ *
10
+ * `route()` üç kapsamı belirli bir sırayla iç içe kurar:
11
+ * withHtmlCache( withUpstreamTracking( withRequestCache( controller ) ) )
12
+ * Sıra önemli — istek içi cache en içte olmalı ki aynı render'daki iki
13
+ * çağrı tek upstream isteğine düşsün; upstream takibi HTML cache'in içinde
14
+ * olmalı ki eksik veriyle üretilen çıktı önbelleğe yazılmasın.
15
+ */
16
+ import path from "node:path";
17
+ import process from "node:process";
18
+ import fs from "node:fs";
19
+ import { pathToFileURL } from "node:url";
20
+ import { setEdgeCacheHeaders } from "./cache-control.js";
21
+ import { rememberHtmlEncoding, withHtmlCache } from "./html-cache.js";
22
+ import { buildVaryPrefix } from "./cache-vary.js";
23
+ import { getConfig, hook, FRAMEWORK_ROOT } from "../config/index.js";
24
+ import { matchPattern } from "../config/pattern.js";
25
+ import { encodeText, negotiateEncoding } from "./middleware/compression.js";
26
+ import { navigationHints, preconnectHints } from "./head-hints.js";
27
+ import { withRequestCache } from "../http/request-cache.js";
28
+ import {
29
+ createRequestContext,
30
+ getRequestContext,
31
+ guardRequest,
32
+ withRequestContext,
33
+ } from "../http/request-context.js";
34
+ import {
35
+ getUpstreamFailures,
36
+ isTransientStatus,
37
+ withUpstreamTracking,
38
+ } from "./upstream-tracking.js";
39
+ import { isNotFoundError, isRedirectError } from "../http/control-flow.js";
40
+ import { renderHeadMeta } from "./metadata.js";
41
+ import { asset, hasAsset } from "./assets.js";
42
+ import * as html from "../views/helpers/html.js";
43
+ import * as tags from "../views/helpers/tags.js";
44
+ import { loadComponents } from "../views/components/loader.js";
45
+ import { renderStatusPage } from "./status-page.js";
46
+ import { suppressForPrewarm, noteVisitWarm } from "./prewarm.js";
47
+ import {
48
+ ensureTemplatesCompiled,
49
+ getComponentDirs,
50
+ getViewRoots,
51
+ } from "../compile/index.js";
52
+ import { renderEjsFile } from "./ejs-adapter.js";
53
+
54
+ const isDev = process.env.NODE_ENV === "development";
55
+
56
+ /**
57
+ * Şablonlara otomatik geçen yardımcılar ve (legacy) EJS ayarları. Bileşen
58
+ * taraması dosya sistemine dokunduğu için bir kez yapılır; config yüklenmeden
59
+ * hesaplanamaz, bu yüzden ilk render'da kurulur.
60
+ *
61
+ * @type {{ helpers: Record<string, unknown>, options: Record<string, unknown>,
62
+ * viewsDir: string, viewRoots: string[], layout: string,
63
+ * compiled: Map<string, (data: object, helpers: object) => string>,
64
+ * layoutRender: ((data: object, helpers: object) => string) | null
65
+ * } | null}
66
+ */
67
+ let engine = null;
68
+
69
+ /**
70
+ * @returns {Promise<NonNullable<typeof engine>>}
71
+ */
72
+ async function getEngine() {
73
+ if (engine) return engine;
74
+
75
+ const config = getConfig();
76
+ const viewsDir = config.dirs.views;
77
+ const viewRoots = getViewRoots(config);
78
+
79
+ await ensureTemplatesCompiled(config);
80
+
81
+ /** @type {Record<string, unknown>} */
82
+ const helpers = {
83
+ ...html,
84
+ ...tags,
85
+ ...(await loadComponents(getComponentDirs(config))),
86
+ asset,
87
+ hasAsset,
88
+ // Yerleşik PascalCase etiketler (.jsk bileşen sözdizimi).
89
+ Link: (props) => tags.link(props),
90
+ Image: (props) => tags.image(props),
91
+ Icon: (props) => tags.icon(props),
92
+ CsrfField: () => tags.csrfField(),
93
+ PreloadImage: (props) => tags.preloadImage(props),
94
+ Stylesheets: (props) => tags.stylesheets(props),
95
+ BodyScripts: (props) => tags.bodyScripts(props),
96
+ JsonLd: (props) => tags.jsonLd(props),
97
+ };
98
+
99
+ // camelCase JS bileşenlerini PascalCase etiket adıyla da erişilebilir yap.
100
+ for (const [name, value] of Object.entries(helpers)) {
101
+ if (typeof value !== "function") continue;
102
+ if (/^[A-Z]/.test(name)) continue;
103
+ const pascal = name.charAt(0).toUpperCase() + name.slice(1);
104
+ if (!(pascal in helpers)) helpers[pascal] = value;
105
+ }
106
+
107
+ const { compiled, layoutRender: appLayoutRender } = await loadCompiledTemplates(
108
+ config,
109
+ helpers,
110
+ );
111
+
112
+ // Yalnızca framework varsayılan layout'una düşüldüğünde hazır modülü kullan;
113
+ // uygulamanın kendi layout.jsk'si derlenmemişse sessizce framework'e kayma.
114
+ let layoutRender = appLayoutRender;
115
+ if (!layoutRender) {
116
+ const fwLayout = path.join(FRAMEWORK_ROOT, "src", "templates", "layout.jsk");
117
+ if (path.resolve(config.layout) === path.resolve(fwLayout)) {
118
+ const fw = await import("../templates/layout.render.js");
119
+ if (typeof fw.render === "function") layoutRender = fw.render;
120
+ }
121
+ }
122
+
123
+ engine = {
124
+ viewsDir,
125
+ viewRoots,
126
+ layout: config.layout,
127
+ helpers,
128
+ compiled,
129
+ layoutRender,
130
+ options: {
131
+ // `include('partials/header')` gibi çağrılar views kökünden çözülür.
132
+ root: viewsDir,
133
+ views: viewRoots.length ? viewRoots : [viewsDir],
134
+ cache: !isDev,
135
+ rmWhitespace: true,
136
+ async: true,
137
+ },
138
+ };
139
+
140
+ return engine;
141
+ }
142
+
143
+ /**
144
+ * Derlenmiş `.jsk` modüllerini yükler. İstek anında parse/compile yok.
145
+ *
146
+ * @param {import('../config/index.js').ResolvedConfig} config
147
+ * @param {Record<string, unknown>} helpers
148
+ * @returns {Promise<{ compiled: Map<string, Function>, layoutRender: Function | null }>}
149
+ */
150
+ async function loadCompiledTemplates(config, helpers) {
151
+ /** @type {Map<string, (data: object, helpers: object) => string>} */
152
+ const compiled = new Map();
153
+ const templatesDir = path.join(config.dirs.generated, "templates");
154
+ const manifestPath = path.join(templatesDir, "manifest.json");
155
+
156
+ if (!fs.existsSync(manifestPath)) {
157
+ return { compiled, layoutRender: null };
158
+ }
159
+
160
+ /** @type {Record<string, string>} */
161
+ const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
162
+ const bust = isDev ? `?t=${Date.now()}` : "";
163
+
164
+ for (const [viewId, rel] of Object.entries(manifest)) {
165
+ const file = path.join(templatesDir, rel);
166
+ if (!fs.existsSync(file)) continue;
167
+ const mod = await import(pathToFileURL(file).href + bust);
168
+ if (typeof mod.render !== "function") continue;
169
+ compiled.set(viewId, mod.render);
170
+
171
+ // `components/*.jsk` → helpers.PascalName
172
+ if (viewId.startsWith("components/")) {
173
+ const base = viewId.slice("components/".length).split("/").pop() ?? "";
174
+ const pascal = base
175
+ .split(/[-_/]+/)
176
+ .filter(Boolean)
177
+ .map((p) => p.charAt(0).toUpperCase() + p.slice(1))
178
+ .join("");
179
+ if (pascal) {
180
+ helpers[pascal] = (props) => mod.render(props ?? {}, helpers);
181
+ }
182
+ }
183
+ }
184
+
185
+ const layoutRender = compiled.get("layout") ?? null;
186
+ return { compiled, layoutRender };
187
+ }
188
+
189
+ /**
190
+ * View id için kaynak `.ejs` dosyasını çoklu kökte ara.
191
+ * @param {string[]} viewRoots
192
+ * @param {string} view
193
+ * @returns {string | null}
194
+ */
195
+ function findEjsView(viewRoots, view) {
196
+ for (const root of viewRoots) {
197
+ const file = path.join(root, `${view}.ejs`);
198
+ if (fs.existsSync(file)) return file;
199
+ }
200
+ return null;
201
+ }
202
+
203
+ /**
204
+ * Uygulama geliştirirken bileşen dosyaları değişince kayıt yenilenmeli.
205
+ * Dev sunucusu süreci yeniden başlattığı için normalde gerekmez; gömülü
206
+ * kullanımlar (test, script) için dışa açık.
207
+ *
208
+ * @returns {void}
209
+ */
210
+ export function resetRenderEngine() {
211
+ engine = null;
212
+ }
213
+
214
+ /**
215
+ * Layout kullanmadan tek bir şablon render eder. Fragment/partial uçları
216
+ * ve e-posta şablonları bunu kullanır.
217
+ *
218
+ * Öncelik: derlenmiş `.jsk` → `.ejs`. İstek anında şablon derlenmez.
219
+ *
220
+ * @param {string} view `views/` altındaki yol, uzantısız (örn. "pages/home")
221
+ * @param {object} [data]
222
+ * @returns {Promise<string>}
223
+ */
224
+ export async function renderView(view, data = {}) {
225
+ const { helpers, options, compiled, viewRoots, viewsDir } = await getEngine();
226
+ const renderFn = compiled.get(view);
227
+ if (renderFn) {
228
+ return renderFn({ ...data }, helpers);
229
+ }
230
+
231
+ const file =
232
+ findEjsView(viewRoots.length ? viewRoots : [viewsDir], view) ??
233
+ path.join(viewsDir, `${view}.ejs`);
234
+ return renderEjsFile(file, { ...helpers, ...data }, options);
235
+ }
236
+
237
+ /**
238
+ * Sayfayı layout içinde render eder.
239
+ *
240
+ * @param {{ view: string, data?: object, metadata?: object, head?: string,
241
+ * bodyClass?: string, entries?: string[], styles?: string[],
242
+ * pathname?: string }} page
243
+ * @returns {Promise<string>}
244
+ */
245
+ export async function renderPage(page) {
246
+ const { helpers, options, layout, layoutRender } = await getEngine();
247
+ const config = getConfig();
248
+
249
+ const metadata = {
250
+ ...(await hook("metadata", {}, page)),
251
+ ...(page.metadata ?? {}),
252
+ };
253
+
254
+ // Layout bağlamı ve gövde paralel üretilir: navigasyon çoğu projede
255
+ // upstream'den geliyor ve gövde render'ıyla sırayla beklemek her sayfaya
256
+ // gereksiz gecikme ekliyor.
257
+ const [body, context] = await Promise.all([
258
+ renderView(page.view, { ...(page.data ?? {}), metadata }),
259
+ hook("layoutContext", {}, { pathname: page.pathname ?? "", metadata }),
260
+ ]);
261
+
262
+ const locals = {
263
+ ...helpers,
264
+ ...context,
265
+ metadata,
266
+ // Boş varsayılan bilinçli: "/" yazmak her sayfayı ana sayfa sanıp
267
+ // logoyu <h1> olarak bastıran türde hatalara yol açıyor.
268
+ pathname: page.pathname ?? "",
269
+ lang: context.lang ?? config.brand.lang ?? "en",
270
+ headMeta: renderHeadMeta(metadata),
271
+ structuredData: context.structuredData ?? [],
272
+ // Preconnect her sayfada aynı; LCP preload'ını sayfa kendisi ekler.
273
+ // Gezinme ipuçları preconnect'ten sonra: spekülasyon bir sonraki sayfayı
274
+ // ilgilendiriyor, bu sayfanın LCP'sinin önüne geçmemeli.
275
+ extraHead:
276
+ preconnectHints() +
277
+ navigationHints() +
278
+ (page.head ?? "") +
279
+ (context.extraHead ?? ""),
280
+ bodyClass: page.bodyClass ?? context.bodyClass ?? "",
281
+ entries: page.entries ?? [],
282
+ styles: page.styles ?? [],
283
+ devtools: isDev,
284
+ devBasePath: config.brand.devBasePath,
285
+ body,
286
+ };
287
+
288
+ if (layoutRender) {
289
+ return layoutRender(locals, helpers);
290
+ }
291
+
292
+ if (layout.endsWith(".jsk")) {
293
+ // Kaynak var ama derlenmemiş — ensureTemplates sonrası olmamalı.
294
+ throw new Error(
295
+ `Layout ${path.relative(config.root, layout)} is not compiled — run jskelet build`,
296
+ );
297
+ }
298
+
299
+ return renderEjsFile(layout, locals, options);
300
+ }
301
+
302
+ /**
303
+ * Kişiye özel yanıtın cache direktifi. Dinamik (cache'lenmeyen) her sayfa da
304
+ * bunu alır: bir yanıt hiçbir direktif taşımadığında HTTP onu "sezgisel olarak
305
+ * cache'lenebilir" sayar ve araya giren bir proxy ya da tarayıcının geri
306
+ * tuşu kullanıcıya özel HTML'i saklayabilir.
307
+ */
308
+ const PRIVATE_CACHE = "private, no-store";
309
+
310
+ /**
311
+ * Controller'ı çalıştırıp yanıtı yazar; notFound/redirect kontrol akışını,
312
+ * HTML cache'ini ve hata yönetimini üstlenir.
313
+ *
314
+ * `private: true` kişiye özel sayfaları public cache yolundan tamamen ayırır:
315
+ * HTML cache devre dışı kalır, config'in `cache.html` deseni bu kararı
316
+ * ezemez, yanıt `no-store` ile ve ETag'siz gider. Dashboard tipi sayfalarda
317
+ * bu bayrak olmadan çalışmak, bir kullanıcının HTML'inin bir başkasına
318
+ * servis edilmesi anlamına gelir.
319
+ *
320
+ * @param {(ctx: { params: object, query: object, pathname: string,
321
+ * req: import('express').Request }) => Promise<object>} controller
322
+ * @param {{ revalidate?: number, private?: boolean }} [options]
323
+ * @returns {import('express').RequestHandler}
324
+ */
325
+ export function route(controller, options = {}) {
326
+ const isPrivate = options.private === true;
327
+
328
+ return async (req, res, next) => {
329
+ const context = createRequestContext({
330
+ private: isPrivate,
331
+ res,
332
+ pathname: req.path,
333
+ });
334
+ const ctx = {
335
+ params: req.params ?? {},
336
+ query: req.query ?? {},
337
+ pathname: req.path,
338
+ // Cookie/Authorization okunursa çıktı kullanıcıya bağlıdır; cache'e
339
+ // yazılmaması için işaretlenmesi gerekiyor.
340
+ req: guardRequest(req),
341
+ };
342
+
343
+ // Private route'ta desen taraması hiç yapılmaz: `cache.html` altındaki
344
+ // geniş bir kural (`/**` gibi) bu sayfayı cache'lenebilir hâle
345
+ // getirmesin. Kilit tek yönlü — route "özel" dediyse config açamaz.
346
+ const revalidate = isPrivate
347
+ ? undefined
348
+ : resolveRevalidate(req.path, options.revalidate);
349
+ // Anahtar `null` ise query bu yol için cache'lenebilir değil: sayfa
350
+ // dinamik davranır. Anahtar yine de gerekiyor (hata sayfası ölçümü,
351
+ // teşhis) ama TTL sıfırlanıp cache yolu kapatılır.
352
+ const key = buildCacheKey(req.path, ctx.query, req);
353
+ const cacheable =
354
+ !isPrivate && req.method === "GET" && Boolean(revalidate) && key !== null;
355
+ const cacheKey =
356
+ key ??
357
+ `${buildVaryPrefix(req)}${req.path}?${new URLSearchParams(
358
+ Object.entries(ctx.query).map(([k, v]) => [k, String(v)]),
359
+ ).toString()}`;
360
+
361
+ try {
362
+ const result = await withRequestContext(context, () =>
363
+ withHtmlCache(cacheKey, cacheable ? revalidate : 0, () =>
364
+ withUpstreamTracking(() => withRequestCache(() => produce(controller, ctx))),
365
+ ),
366
+ );
367
+
368
+ // Cache'lenebilir bir route kimliğe dokunduysa yanıt yine gider ama
369
+ // saklanmaz (`produce` bunu `storable: false` ile bildirdi) ve public
370
+ // direktif yazılmaz.
371
+ const leaked = cacheable && context.tainted;
372
+ if (leaked && isDev) {
373
+ throw new Error(
374
+ `[render] ${req.path} is a cacheable route but read identity-bound data ` +
375
+ `(${context.taintReasons.join(", ")}). This page must be registered with ` +
376
+ `'route(fn, { private: true })'; otherwise one user's HTML is served to another.`,
377
+ );
378
+ }
379
+
380
+ // Eksik veriyle üretilen çıktı süreç içi önbelleğe yazılmıyor; aynı
381
+ // çıktıya edge'de taze pencere vermek o kararı bir katman yukarıda geri
382
+ // almak olurdu. Geçici bir 429 yüzünden üretilen 503, ters proxy'de
383
+ // dakikalarca yaşamamalı.
384
+ const publicCache = cacheable && !leaked && !result.degraded;
385
+
386
+ res.status(result.status);
387
+ res.setHeader("Content-Type", "text/html; charset=utf-8");
388
+
389
+ // Geçici upstream hatası: istemciye ve bota "bu kalıcı değil, sonra gel"
390
+ // demenin standart yolu.
391
+ if (result.retryAfter) {
392
+ res.setHeader("Retry-After", String(result.retryAfter));
393
+ }
394
+
395
+ // Teşhis başlığı `degraded` yanıtta da yazılır: "MISS" görmek, sayfanın
396
+ // önbellek yolundan geçtiğini ama saklanmadığını anlatan tek ipucu.
397
+ if (cacheable && !leaked) {
398
+ res.setHeader(
399
+ getConfig().brand.cacheHeader,
400
+ result.cached ? (result.stale ? "STALE" : "HIT") : "MISS",
401
+ );
402
+ }
403
+
404
+ if (publicCache) {
405
+ // Tarayıcıda max-age=0 kalır; edge süresi HTML TTL, stale penceresi
406
+ // `cache().staleWhileRevalidate`. s-maxage yazılmaz.
407
+ setEdgeCacheHeaders(res, revalidate, getConfig().staleWhileRevalidate);
408
+ } else {
409
+ res.setHeader("Cache-Control", PRIVATE_CACHE);
410
+ // Anahtarında cookie olmayan bir cache'in bu yanıtı paylaşmasını
411
+ // engeller; `no-store`'a uymayan bir katman için ikinci savunma.
412
+ res.setHeader("Vary", "Cookie");
413
+
414
+ if (leaked) {
415
+ console.warn(
416
+ `[render] ${req.path} read identity-bound data (${context.taintReasons.join(", ")}), ` +
417
+ `not cached. The route should be registered with 'private: true'.`,
418
+ );
419
+ }
420
+ }
421
+
422
+ // ETag kişiye özel HTML için kullanıcıya özgü bir doğrulayıcıdır ve
423
+ // `no-store` ile birlikte hiçbir işe yaramaz; üretilmesi engellenir.
424
+ await sendHtml(req, res, result.html, result.encoded, { etag: publicCache });
425
+
426
+ // onVisit: yanıt gittikten sonra linkleri kuyruğa al — TTFB'yi şişirmez.
427
+ // Yalnızca herkese açık, önbelleklenebilir 200 HTML; private / degraded
428
+ // sayfadaki linkler kişiye özel veya eksik olabilir.
429
+ if (publicCache && result.status === 200 && result.html) {
430
+ const pagePath = req.path;
431
+ const pageHtml = result.html;
432
+ queueMicrotask(() => {
433
+ noteVisitWarm(pageHtml, { path: pagePath, req });
434
+ });
435
+ }
436
+ } catch (error) {
437
+ if (isRedirectError(error)) {
438
+ // Oturuma bağlı bir yönlendirme de kişiye özeldir: "giriş yapmalısın"
439
+ // kararının cache'lenmesi, oturum açmış kullanıcıyı da login sayfasına
440
+ // atan türde hatalara yol açıyor.
441
+ if (isPrivate || context.tainted) {
442
+ res.setHeader("Cache-Control", PRIVATE_CACHE);
443
+ res.setHeader("Vary", "Cookie");
444
+ }
445
+
446
+ res.redirect(error.statusCode, error.location);
447
+ return;
448
+ }
449
+ next(error);
450
+ }
451
+ };
452
+ }
453
+
454
+ /**
455
+ * Layout'suz, asla cache'lenmeyen parça yanıtı.
456
+ *
457
+ * Fragment uçları (tablo sayfası, sekme paneli, canlı tazelenen kart) her
458
+ * projede elle yazılıyor ve `no-store` yazmayı unutmak sessiz bir sızıntıya
459
+ * dönüşüyor. Burada politika sabit: HTML cache'e hiç uğramaz, `no-store` ile
460
+ * ve ETag'siz gider.
461
+ *
462
+ * Hata durumunda tüm sayfa yerine küçük bir hata parçası döner: takas edilen
463
+ * bölge bir hata sayfasının tamamını içine almasın.
464
+ *
465
+ * @param {(ctx: { params: object, query: object, pathname: string,
466
+ * req: import('express').Request }) => Promise<{ view: string, data?: object,
467
+ * status?: number } | string>} controller
468
+ * @returns {import('express').RequestHandler}
469
+ */
470
+ export function fragment(controller) {
471
+ return async (req, res, next) => {
472
+ const context = createRequestContext({
473
+ private: true,
474
+ res,
475
+ pathname: req.path,
476
+ });
477
+ const ctx = {
478
+ params: req.params ?? {},
479
+ query: req.query ?? {},
480
+ pathname: req.path,
481
+ req: guardRequest(req),
482
+ };
483
+
484
+ try {
485
+ const result = await withRequestContext(context, () =>
486
+ withRequestCache(async () => {
487
+ const value = await controller(ctx);
488
+ if (typeof value === "string") return { html: value, status: 200 };
489
+
490
+ return {
491
+ html: await renderView(value.view, value.data ?? {}),
492
+ status: value.status ?? 200,
493
+ };
494
+ }),
495
+ );
496
+
497
+ res.status(result.status);
498
+ res.setHeader("Content-Type", "text/html; charset=utf-8");
499
+ res.setHeader("Cache-Control", PRIVATE_CACHE);
500
+ res.setHeader("Vary", "Cookie");
501
+ await sendHtml(req, res, result.html, undefined, { etag: false });
502
+ } catch (error) {
503
+ if (isRedirectError(error)) {
504
+ res.setHeader("Cache-Control", PRIVATE_CACHE);
505
+ res.redirect(error.statusCode, error.location);
506
+ return;
507
+ }
508
+
509
+ if (isNotFoundError(error)) {
510
+ res.status(404).setHeader("Cache-Control", PRIVATE_CACHE);
511
+ res.type("html").send(fragmentError("notFound"));
512
+ return;
513
+ }
514
+
515
+ console.error(`[fragment] ${req.method} ${req.originalUrl}`, error);
516
+
517
+ if (res.headersSent) {
518
+ next(error);
519
+ return;
520
+ }
521
+
522
+ res.status(500).setHeader("Cache-Control", PRIVATE_CACHE);
523
+ res.type("html").send(fragmentError("failed"));
524
+ }
525
+ };
526
+ }
527
+
528
+ /**
529
+ * Ziyaretçiye görünen parça hatası metinleri. Framework'ün kendi logları
530
+ * İngilizce ama bu satırlar ekranda okunuyor, bu yüzden durum sayfalarıyla
531
+ * aynı kuralı izliyorlar: dil `brand.lang`.
532
+ */
533
+ const FRAGMENT_MESSAGES = {
534
+ tr: { notFound: "Bu içerik bulunamadı.", failed: "Bu bölüm yüklenemedi." },
535
+ en: { notFound: "This content was not found.", failed: "This section could not be loaded." },
536
+ };
537
+
538
+ /**
539
+ * Fragment hatası için minimal işaretleme. Şablona bağlı olmaması bilinçli:
540
+ * hata yolu, hatanın kaynağı olabilecek render katmanına geri dönmemeli.
541
+ *
542
+ * @param {"notFound" | "failed"} kind
543
+ * @returns {string}
544
+ */
545
+ function fragmentError(kind) {
546
+ let lang = "en";
547
+ try {
548
+ lang = getConfig().brand.lang ?? "en";
549
+ } catch {
550
+ /* config yüklenmemişse İngilizce kalır */
551
+ }
552
+
553
+ const table = FRAGMENT_MESSAGES[lang.slice(0, 2).toLowerCase()] ?? FRAGMENT_MESSAGES.en;
554
+ return `<div role="alert" data-fragment-error>${html.esc(table[kind])}</div>`;
555
+ }
556
+
557
+ /**
558
+ * `jskelet.config.mjs` → `cache().html` route'un kendi `revalidate`'ini ezer.
559
+ * Sonuç yol başına hatırlanır: her istekte desen taraması yapılmaz.
560
+ *
561
+ * @type {Map<string, number | undefined>}
562
+ */
563
+ const revalidateByPath = new Map();
564
+
565
+ /**
566
+ * Yakalayıcı bir route'ta (`/:slug`) her benzersiz yol burada kalıcı bir girdi
567
+ * bırakıyor; sınır olmadan bu, uzun ömürlü süreçte bellek sızıntısına dönüşür.
568
+ * Desen taraması ucuz olduğu için en eski girdileri atmak güvenli.
569
+ */
570
+ const REVALIDATE_CACHE_MAX = 2000;
571
+
572
+ /**
573
+ * Aynı gerekçeyle (yakalayıcı route'ta sınırsız büyüme) query kuralı da yol
574
+ * başına hatırlanır.
575
+ *
576
+ * @type {Map<string, true | string[] | null>}
577
+ */
578
+ const queryPolicyByPath = new Map();
579
+
580
+ /**
581
+ * @param {string} pathname
582
+ * @returns {true | string[] | null} `null`: bu yol için hiçbir parametreye
583
+ * izin verilmiyor.
584
+ */
585
+ function resolveQueryPolicy(pathname) {
586
+ if (queryPolicyByPath.has(pathname)) {
587
+ return queryPolicyByPath.get(pathname) ?? null;
588
+ }
589
+
590
+ const rules = getConfig().cacheQuery;
591
+ const match = rules.find((rule) => matchPattern(rule.pattern, pathname));
592
+
593
+ if (queryPolicyByPath.size >= REVALIDATE_CACHE_MAX) {
594
+ const oldest = queryPolicyByPath.keys().next().value;
595
+ if (oldest !== undefined) queryPolicyByPath.delete(oldest);
596
+ }
597
+
598
+ const policy = match ? match.allow : null;
599
+ queryPolicyByPath.set(pathname, policy);
600
+ return policy;
601
+ }
602
+
603
+ /**
604
+ * HTML cache anahtarı, ya da query bu yol için cache'lenebilir değilse `null`.
605
+ *
606
+ * Biçim: `${varyPrefix}${pathname}?${izin verilen query}`.
607
+ * Vary (`cache().vary`) query allowlist'ten bağımsız; host/locale sitelerinde
608
+ * ilk host'un HTML'inin diğerine servis edilmesini engeller.
609
+ *
610
+ * Varsayılan olarak query parametresi taşıyan istek dinamiktir: `cache().query`
611
+ * altında eşleşen bir kural olmadıkça cache'e hiç girmez. Aksi hâlde bir yolun
612
+ * bütün `?utm_source=…` varyantları ayrı girdi olur ve LRU'daki gerçek
613
+ * sayfaları dışarı atar.
614
+ *
615
+ * İzin verilen parametreler **sıralı** yazılır: `?a=1&b=2` ile `?b=2&a=1` aynı
616
+ * sayfa olduğu için aynı anahtarı almalı.
617
+ *
618
+ * @param {string} pathname
619
+ * @param {Record<string, unknown>} query
620
+ * @param {{ headers?: Record<string, unknown>, get?: (name: string) => string | undefined }} [req]
621
+ * @returns {string | null}
622
+ */
623
+ function buildCacheKey(pathname, query, req) {
624
+ const vary = buildVaryPrefix(req);
625
+ const entries = Object.entries(query);
626
+ if (!entries.length) return `${vary}${pathname}?`;
627
+
628
+ const policy = resolveQueryPolicy(pathname);
629
+ if (policy === null) return null;
630
+
631
+ const kept =
632
+ policy === true ? entries : entries.filter(([name]) => policy.includes(name));
633
+
634
+ // İzin listesi dışındaki parametreler anahtara girmez: sayfa cache'lenir ve
635
+ // bütün kampanya varyantları tek kopyayı paylaşır.
636
+ const params = new URLSearchParams(
637
+ kept
638
+ .map(/** @returns {[string, string]} */ ([name, value]) => [name, String(value)])
639
+ .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)),
640
+ );
641
+
642
+ return `${vary}${pathname}?${params.toString()}`;
643
+ }
644
+
645
+ /**
646
+ * @param {string} pathname
647
+ * @param {number | undefined} fallback
648
+ * @returns {number | undefined}
649
+ */
650
+ function resolveRevalidate(pathname, fallback) {
651
+ const rules = getConfig().html;
652
+ if (!rules.length) return fallback;
653
+
654
+ if (revalidateByPath.has(pathname)) {
655
+ return revalidateByPath.get(pathname) ?? fallback;
656
+ }
657
+
658
+ const match = rules.find((rule) => matchPattern(rule.pattern, pathname));
659
+
660
+ if (revalidateByPath.size >= REVALIDATE_CACHE_MAX) {
661
+ const oldest = revalidateByPath.keys().next().value;
662
+ if (oldest !== undefined) revalidateByPath.delete(oldest);
663
+ }
664
+
665
+ revalidateByPath.set(pathname, match?.seconds);
666
+ return match ? match.seconds : fallback;
667
+ }
668
+
669
+ /**
670
+ * Cache'lenmiş bir sayfa aynı gövdeyi her istekte yeniden sıkıştırmasın diye
671
+ * brotli/gzip çıktısı HTML ile birlikte saklanır. `Content-Encoding` burada
672
+ * ayarlandığı için sıkıştırma middleware'i devreye girmez.
673
+ *
674
+ * @param {import('express').Request} req
675
+ * @param {import('express').Response} res
676
+ * @param {string} body
677
+ * @param {Map<string, Buffer>} [encoded]
678
+ * @param {{ etag?: boolean }} [options] `etag: false` → `res.send()` atlanır,
679
+ * böylece Express kullanıcıya özel gövde için doğrulayıcı üretmez.
680
+ * @returns {Promise<void>}
681
+ */
682
+ async function sendHtml(req, res, body, encoded, options = {}) {
683
+ const encoding =
684
+ req.method === "HEAD" ? null : negotiateEncoding(req.headers["accept-encoding"]);
685
+
686
+ if (!encoding || !encoded) {
687
+ if (options.etag === false) {
688
+ // Sıkıştırma middleware'i devreye girerse Content-Length'i kendisi
689
+ // kaldırır; girmezse doğru uzunlukla gider.
690
+ res.setHeader("Content-Length", String(Buffer.byteLength(body)));
691
+ res.end(req.method === "HEAD" ? undefined : body);
692
+ return;
693
+ }
694
+
695
+ res.send(body);
696
+ return;
697
+ }
698
+
699
+ let buffer = encoded.get(encoding);
700
+ if (!buffer) {
701
+ buffer = await encodeText(body, encoding);
702
+ // Ham HTML kalır; sıkıştırılmış kopya tek kodlamadır. Diğer istemci
703
+ // gelince eski buffer düşer, bayt bütçesi iki gövdeyi birden saymaz.
704
+ rememberHtmlEncoding(encoded, encoding, buffer);
705
+ }
706
+
707
+ res.setHeader("Content-Encoding", encoding);
708
+ res.setHeader("Vary", "Accept-Encoding");
709
+ res.setHeader("Content-Length", String(buffer.length));
710
+ res.end(buffer);
711
+ }
712
+
713
+ /**
714
+ * @typedef {{ html: string, status: number, degraded?: boolean,
715
+ * storable?: boolean, retryAfter?: number }} Produced
716
+ */
717
+
718
+ /**
719
+ * Controller'ı bir kez çalıştırır. `notFound()` fırlatıldığında sonucu
720
+ * ayırt edilebilir biçimde döner: çağıran taraf bunun gerçek bir 404 mü,
721
+ * yoksa upstream düştüğü için verinin gelmemesi mi olduğuna karar verecek.
722
+ *
723
+ * @param {Function} controller
724
+ * @param {{ pathname: string }} ctx
725
+ * @returns {Promise<{ page: Produced } | { notFound: true,
726
+ * transient: import('./upstream-tracking.js').UpstreamFailure[] }>}
727
+ */
728
+ async function attempt(controller, ctx) {
729
+ try {
730
+ const page = await controller(ctx);
731
+ const rendered = await renderPage({ pathname: ctx.pathname, ...page });
732
+ return {
733
+ page: {
734
+ html: rendered,
735
+ status: page.status ?? 200,
736
+ degraded: hasUpstreamFailures(ctx.pathname),
737
+ // Kimliğe bağlı çıktı önbelleğe yazılmaz. Karar burada verilmeli:
738
+ // `withHtmlCache` yazma anında controller'ın ne okuduğunu bilemez.
739
+ storable: getRequestContext()?.tainted !== true,
740
+ },
741
+ };
742
+ } catch (error) {
743
+ if (isNotFoundError(error)) {
744
+ return { notFound: true, transient: transientUpstreamFailures() };
745
+ }
746
+ throw error;
747
+ }
748
+ }
749
+
750
+ /**
751
+ * @param {Function} controller
752
+ * @param {{ pathname: string }} ctx
753
+ * @returns {Promise<Produced>}
754
+ */
755
+ async function produce(controller, ctx) {
756
+ const first = await attempt(controller, ctx);
757
+ if ("page" in first) return first.page;
758
+ // Deterministik "böyle bir sayfa yok" cevabı: tekrar denemenin anlamı yok.
759
+ if (!first.transient.length) return { html: await renderNotFound(), status: 404 };
760
+
761
+ // Buraya gelindiyse `notFound()` veri gelmediği için çağrılmış. **Var olan
762
+ // bir sayfayı** 404 olarak servis etmek en kötü sonuç: arama motoru geçici
763
+ // bir rate limit'i kalıcı bir kayıp sanar. Bu yüzden sayfa yeniden denenir —
764
+ // ısıtma günlükleri gösteriyor ki aynı yol saniyeler sonra 200 dönüyor.
765
+ const { attempts, delayMs } = transientRetry();
766
+ let failures = first.transient;
767
+
768
+ for (let round = 1; round <= attempts; round += 1) {
769
+ const retryDetail =
770
+ `returned notFound() while upstream is failing ` +
771
+ `(${summarizeFailures(failures)}), retrying (${round}/${attempts})`;
772
+ if (!suppressForPrewarm(retryDetail)) {
773
+ console.warn(`[render] ${ctx.pathname} ${retryDetail}`);
774
+ }
775
+
776
+ // Beklemeden tekrar denemek rate limit'e girmiş bir API'de aynı 429'u
777
+ // getirir; kısa bekleme hem pencerenin dönmesine şans verir hem de
778
+ // fırtınayı büyütmez.
779
+ await sleep(delayMs * round);
780
+
781
+ // Her deneme kendi upstream ve istek içi cache bağlamında çalışır: ilk
782
+ // turun hataları ikinci turun kararını kirletmesin ve memoize edilmiş
783
+ // boş cevaplar tekrar kullanılmasın.
784
+ const retried = await withUpstreamTracking(() =>
785
+ withRequestCache(() => attempt(controller, ctx)),
786
+ );
787
+
788
+ if ("page" in retried) return retried.page;
789
+ if (!retried.transient.length) {
790
+ // Bu kez upstream sağlam cevap verdi ve "yok" dedi: gerçek 404.
791
+ return { html: await renderNotFound(), status: 404 };
792
+ }
793
+
794
+ failures = retried.transient;
795
+ }
796
+
797
+ // Denemeler tükendi. 404 yerine 503: önbelleğe girmez, `Retry-After` taşır
798
+ // ve bir sonraki istek gerçek içeriği üretebilir.
799
+ const outageDetail =
800
+ `could not be produced, upstream is still failing ` +
801
+ `(${summarizeFailures(failures)}), serving an uncached 503 instead of a 404`;
802
+ if (!suppressForPrewarm(outageDetail)) {
803
+ console.warn(`[render] ${ctx.pathname} ${outageDetail}`);
804
+ }
805
+
806
+ return {
807
+ html: await renderStatusPage(503),
808
+ status: 503,
809
+ degraded: true,
810
+ retryAfter: RETRY_AFTER_SECONDS,
811
+ };
812
+ }
813
+
814
+ /**
815
+ * Geçici bir upstream hatası yüzünden üretilemeyen sayfanın `Retry-After`
816
+ * değeri. Kısa tutuluyor: ziyaretçi de bot da birkaç saniye sonra gerçek
817
+ * içeriği bulabilsin.
818
+ */
819
+ const RETRY_AFTER_SECONDS = 30;
820
+
821
+ /**
822
+ * Tekrar denemenin maliyeti upstream'e binen ikinci bir istek turu; bu yüzden
823
+ * varsayılan tek deneme ve kısa bekleme. Rate limit fırtınasında toplam yük
824
+ * iki katına çıkabilir, ama alternatifi var olan sayfaları 404'e düşürmek.
825
+ *
826
+ * @returns {{ attempts: number, delayMs: number }}
827
+ */
828
+ function transientRetry() {
829
+ const raw = /** @type {any} */ (getConfig().transientRetry ?? {});
830
+ const attempts = Number(raw.attempts);
831
+ const delayMs = Number(raw.delayMs);
832
+
833
+ return {
834
+ attempts: Number.isFinite(attempts) && attempts >= 0 ? Math.floor(attempts) : 1,
835
+ delayMs: Number.isFinite(delayMs) && delayMs >= 0 ? delayMs : 300,
836
+ };
837
+ }
838
+
839
+ /** @param {number} ms */
840
+ function sleep(ms) {
841
+ return new Promise((resolve) => {
842
+ setTimeout(resolve, ms).unref?.();
843
+ });
844
+ }
845
+
846
+
847
+ /**
848
+ * Render sırasında upstream düştüyse çıktı eksik veri içeriyor demektir.
849
+ * Böyle bir HTML önbelleğe yazılmaz: sonraki istek yeniden dener.
850
+ *
851
+ * Ancak yalnızca *geçici* hatalar için. 400/403/404 gibi deterministik
852
+ * cevaplar tekrar denemekle düzelmez; onlar yüzünden önbelleği kapatmak
853
+ * sayfayı her ziyarette baştan render etmek olur — içerik yine aynı eksik
854
+ * hâliyle döner, ziyaretçi sadece render süresini öder. Bu yüzden kalıcı
855
+ * hatalar yalnızca loglanır, önbelleği engellemez.
856
+ *
857
+ * @param {string} pathname
858
+ * @returns {boolean}
859
+ */
860
+ function hasUpstreamFailures(pathname) {
861
+ const failures = getUpstreamFailures();
862
+ if (!failures.length) return false;
863
+
864
+ const transient = failures.filter((failure) => isTransientStatus(failure.status));
865
+ const permanent = failures.filter((failure) => !isTransientStatus(failure.status));
866
+
867
+ // Isıtma turu yüzlerce yolu tarıyor; aynı upstream arızası her yol için bir
868
+ // satır basınca tur özeti kayboluyor. Turun uyarıları sayılıp sonunda
869
+ // toplanır (bkz. `suppressForPrewarm`).
870
+ if (permanent.length) {
871
+ const detail = `missing data, upstream is failing permanently (${summarizeFailures(permanent)})`;
872
+ if (!suppressForPrewarm(detail)) {
873
+ console.warn(`[render] ${pathname} was produced with ${detail}`);
874
+ }
875
+ }
876
+
877
+ if (!transient.length) return false;
878
+
879
+ const detail = `missing data, not caching it (${summarizeFailures(transient)})`;
880
+ if (!suppressForPrewarm(detail)) {
881
+ console.warn(`[render] ${pathname} was produced with ${detail}`);
882
+ }
883
+
884
+ return true;
885
+ }
886
+
887
+ /**
888
+ * @returns {import('./upstream-tracking.js').UpstreamFailure[]}
889
+ */
890
+ function transientUpstreamFailures() {
891
+ return getUpstreamFailures().filter((failure) => isTransientStatus(failure.status));
892
+ }
893
+
894
+ /**
895
+ * @param {import('./upstream-tracking.js').UpstreamFailure[]} failures
896
+ * @returns {string}
897
+ */
898
+ function summarizeFailures(failures) {
899
+ return failures.map((failure) => `${failure.status} ${failure.path}`).join(", ");
900
+ }
901
+
902
+ /**
903
+ * 404 sayfası. `renderStatusPage(404)` için kısayol; route dosyalarında en sık
904
+ * ihtiyaç duyulan durum bu olduğu için ayrı bir ad taşımaya devam ediyor.
905
+ *
906
+ * @returns {Promise<string>}
907
+ */
908
+ export async function renderNotFound() {
909
+ return renderStatusPage(404);
910
+ }