jskelet 0.1.1

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 (72) hide show
  1. package/AGENTS.md +127 -0
  2. package/CHANGELOG.md +40 -0
  3. package/LICENSE +21 -0
  4. package/README.md +342 -0
  5. package/bin/jskelet.mjs +104 -0
  6. package/docs/01-baslangic.md +285 -0
  7. package/docs/02-mimari.md +287 -0
  8. package/docs/03-routing.md +437 -0
  9. package/docs/04-render-ve-sablonlar.md +490 -0
  10. package/docs/05-islands.md +429 -0
  11. package/docs/06-cache.md +409 -0
  12. package/docs/07-yapilandirma.md +673 -0
  13. package/docs/08-build.md +366 -0
  14. package/docs/09-dev-araclari.md +302 -0
  15. package/docs/10-dagitim.md +329 -0
  16. package/docs/11-tasima.md +352 -0
  17. package/docs/README.md +82 -0
  18. package/package.json +97 -0
  19. package/src/build/build.mjs +138 -0
  20. package/src/build/ensure-build.mjs +15 -0
  21. package/src/build/paths.mjs +118 -0
  22. package/src/build/resolve-peer.mjs +36 -0
  23. package/src/build/tasks/client.mjs +268 -0
  24. package/src/build/tasks/css.mjs +124 -0
  25. package/src/build/tasks/fonts.mjs +146 -0
  26. package/src/build/tasks/icons.mjs +224 -0
  27. package/src/build/tasks/images.mjs +244 -0
  28. package/src/build/tasks/precompress.mjs +78 -0
  29. package/src/client/devtools/overlay.js +1763 -0
  30. package/src/client/devtools/report.html +185 -0
  31. package/src/client/devtools/report.js +712 -0
  32. package/src/client/dom.js +95 -0
  33. package/src/client/index.js +26 -0
  34. package/src/client/registry.js +223 -0
  35. package/src/client/safe-image.js +91 -0
  36. package/src/client/store.js +36 -0
  37. package/src/config/defaults.js +102 -0
  38. package/src/config/index.js +433 -0
  39. package/src/config/pattern.js +107 -0
  40. package/src/dev-server.mjs +383 -0
  41. package/src/http/control-flow.js +56 -0
  42. package/src/http/request-cache.js +46 -0
  43. package/src/index.js +35 -0
  44. package/src/init.mjs +220 -0
  45. package/src/log.mjs +332 -0
  46. package/src/logo.png +0 -0
  47. package/src/runtime/alias-hooks.mjs +119 -0
  48. package/src/runtime/register.mjs +4 -0
  49. package/src/server/assets.js +119 -0
  50. package/src/server/create-app.js +167 -0
  51. package/src/server/dev/devtools.js +383 -0
  52. package/src/server/dev/report.js +351 -0
  53. package/src/server/head-hints.js +132 -0
  54. package/src/server/html-cache.js +166 -0
  55. package/src/server/metadata.js +102 -0
  56. package/src/server/middleware/compression.js +205 -0
  57. package/src/server/middleware/dev-gate.js +62 -0
  58. package/src/server/middleware/headers.js +37 -0
  59. package/src/server/middleware/redirects.js +32 -0
  60. package/src/server/middleware/static-precompressed.js +100 -0
  61. package/src/server/middleware/upstream-proxy.js +141 -0
  62. package/src/server/prewarm.js +283 -0
  63. package/src/server/render.js +356 -0
  64. package/src/server/router.js +121 -0
  65. package/src/server/status-page.js +164 -0
  66. package/src/server/upstream-tracking.js +51 -0
  67. package/src/start.mjs +7 -0
  68. package/src/templates/layout.ejs +44 -0
  69. package/src/version.mjs +17 -0
  70. package/src/views/components/loader.js +85 -0
  71. package/src/views/helpers/html.js +102 -0
  72. package/src/views/helpers/tags.js +193 -0
@@ -0,0 +1,283 @@
1
+ /**
2
+ * Sunucu açılışında sayfaları önden render edip HTML cache'ini doldurur.
3
+ *
4
+ * Next'teki build-time prerender'ın karşılığı, ama çıktı diske yazılmaz:
5
+ * HTML cache süreç belleğinde yaşadığı için ısıtma da süreç ayağa kalkınca
6
+ * yapılır. Kazanç aynı — ilk ziyaretçi soğuk render'ı beklemez — fakat veri
7
+ * dondurulmaz: her girdi route'un `revalidate` süresiyle yaşlanır ve
8
+ * stale-while-revalidate ile arkada tazelenir.
9
+ *
10
+ * Isıtma gerçek HTTP istekleriyle yapılır: cache anahtarı, sıkıştırma ve
11
+ * middleware zinciri normal trafikle bire bir aynı olsun. Hangi yolların
12
+ * ısıtılacağını uygulama `hooks.prewarmPaths()` ile bildirir; genelde
13
+ * sitemap üreten fonksiyonun aynısıdır.
14
+ */
15
+ import process from "node:process";
16
+ import { getConfig, hook } from "../config/index.js";
17
+
18
+ /**
19
+ * Isıtmanın canlı durumu. Dev araçları bunu okuyup ilerlemeyi gösterir;
20
+ * üretimde kimse okumazsa da maliyeti bir nesnedir.
21
+ */
22
+ export const prewarmProgress = {
23
+ active: false,
24
+ done: 0,
25
+ total: 0,
26
+ ok: 0,
27
+ failed: 0,
28
+ /** @type {number | null} */
29
+ startedAt: null,
30
+ /** @type {number | null} */
31
+ finishedAt: null,
32
+ /**
33
+ * Denenen her yolun sonucu; dev panelindeki Prewarming sekmesi bunu listeler.
34
+ * @type {{ path: string, status: number, ms: number, bytes: number,
35
+ * cache: string | null, error: string | null }[]}
36
+ */
37
+ entries: [],
38
+ };
39
+
40
+ /** @param {unknown} value @param {number} fallback */
41
+ function num(value, fallback) {
42
+ const parsed = Number(value);
43
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
44
+ }
45
+
46
+ /**
47
+ * Ayar sırası: ortam değişkeni → `jskelet.config.mjs` → kod varsayılanı.
48
+ * Env önde, çünkü tek seferlik deneyler config'i düzenlemeden yapılabilsin.
49
+ *
50
+ * @param {string} envKey
51
+ * @param {string} configKey
52
+ * @param {number} fallback
53
+ * @returns {number}
54
+ */
55
+ function setting(envKey, configKey, fallback) {
56
+ return num(process.env[envKey], num(getConfig().prewarm?.[configKey], fallback));
57
+ }
58
+
59
+ /**
60
+ * @returns {Promise<string[]>}
61
+ */
62
+ async function collectPaths() {
63
+ const { prewarmSkip } = getConfig();
64
+ const paths = await hook("prewarmPaths", []);
65
+
66
+ if (!Array.isArray(paths)) {
67
+ console.warn("[prewarm] hooks.prewarmPaths() dizi döndürmeli, yok sayıldı");
68
+ return [];
69
+ }
70
+
71
+ // Tekilleştirme sırayı korur: liste `PREWARM_MAX` ile budandığı için
72
+ // uygulamanın verdiği öncelik sırası anlamlıdır.
73
+ return [...new Set(paths)].filter(
74
+ (candidate) =>
75
+ typeof candidate === "string" &&
76
+ candidate.startsWith("/") &&
77
+ !prewarmSkip.some((prefix) => candidate.startsWith(prefix)),
78
+ );
79
+ }
80
+
81
+ /**
82
+ * `DEV_TOKEN` ayarlıyken `devGate` token taşımayan her isteğe 404 döner.
83
+ * Isıtma kendi sunucusuna istek attığı için token'ı çerez olarak taşımalı;
84
+ * yoksa tüm sayfalar 404 alır ve önbellek hiç dolmaz.
85
+ *
86
+ * @returns {Record<string, string>}
87
+ */
88
+ function devGateHeader() {
89
+ const token = process.env.DEV_TOKEN;
90
+ if (!token) return {};
91
+
92
+ const cookie = getConfig().brand.devTokenCookie;
93
+ return { cookie: `${cookie}=${encodeURIComponent(token)}` };
94
+ }
95
+
96
+ /**
97
+ * @param {string} origin
98
+ * @param {string[]} paths
99
+ * @param {number} concurrency
100
+ * @param {(ok: number, failed: number) => void} [report]
101
+ * Tur ilerlemesini `prewarmProgress`'e yazar. Tekrar turunda sayaçların
102
+ * anlamı değiştiği için çağıran taraf kendi formülünü verir.
103
+ * @returns {Promise<{ ok: number, failed: number, failedPaths: string[] }>}
104
+ */
105
+ async function crawl(origin, paths, concurrency, report = undefined) {
106
+ const { brand } = getConfig();
107
+ const cacheHeader = brand.cacheHeader.toLowerCase();
108
+
109
+ let index = 0;
110
+ let ok = 0;
111
+ let failed = 0;
112
+ /** @type {string[]} */
113
+ const failedPaths = [];
114
+
115
+ async function worker() {
116
+ while (index < paths.length) {
117
+ const target = paths[index];
118
+ index += 1;
119
+
120
+ const startedAt = Date.now();
121
+
122
+ try {
123
+ const response = await fetch(`${origin}${target}`, {
124
+ headers: {
125
+ // Sıkıştırılmış gövde de cache'lensin.
126
+ "accept-encoding": "br, gzip",
127
+ "user-agent": brand.prewarmUserAgent,
128
+ ...devGateHeader(),
129
+ },
130
+ });
131
+ // Gövde okunmadan bağlantı açık kalır.
132
+ const body = await response.arrayBuffer();
133
+ if (response.ok) ok += 1;
134
+ else {
135
+ failed += 1;
136
+ failedPaths.push(target);
137
+ }
138
+
139
+ prewarmProgress.entries.push({
140
+ path: target,
141
+ status: response.status,
142
+ ms: Date.now() - startedAt,
143
+ bytes: body.byteLength,
144
+ cache: response.headers.get(cacheHeader),
145
+ error: response.ok ? null : `HTTP ${response.status}`,
146
+ });
147
+ } catch (error) {
148
+ failed += 1;
149
+ failedPaths.push(target);
150
+ prewarmProgress.entries.push({
151
+ path: target,
152
+ status: 0,
153
+ ms: Date.now() - startedAt,
154
+ bytes: 0,
155
+ cache: null,
156
+ error: error instanceof Error ? error.message : String(error),
157
+ });
158
+ }
159
+
160
+ if (report) {
161
+ report(ok, failed);
162
+ } else {
163
+ prewarmProgress.done = ok + failed;
164
+ prewarmProgress.ok = ok;
165
+ prewarmProgress.failed = failed;
166
+ }
167
+ }
168
+ }
169
+
170
+ await Promise.all(
171
+ Array.from({ length: Math.min(concurrency, paths.length) }, worker),
172
+ );
173
+
174
+ return { ok, failed, failedPaths };
175
+ }
176
+
177
+ /**
178
+ * @param {{ origin: string, quiet?: boolean, paths?: string[] }} options
179
+ * `paths` verilirse hook çağrılmaz, yalnızca o yollar ısıtılır (dev
180
+ * panelindeki "tekrar dene" bunu kullanır).
181
+ * @returns {Promise<{ ok: number, failed: number, total: number, elapsed: number }>}
182
+ */
183
+ export async function prewarm({ origin, quiet = false, paths: only }) {
184
+ const started = Date.now();
185
+ const limit = setting("PREWARM_MAX", "max", 400);
186
+ // Dev'de daha az paralellik: tarama, o an tarayıcıda açtığın sayfanın
187
+ // render'ıyla CPU için yarışmasın.
188
+ const concurrency = setting(
189
+ "PREWARM_CONCURRENCY",
190
+ "concurrency",
191
+ process.env.NODE_ENV === "development" ? 2 : 4,
192
+ );
193
+
194
+ const all = only?.length ? only : await collectPaths();
195
+ const paths = all.slice(0, limit);
196
+
197
+ Object.assign(prewarmProgress, {
198
+ active: true,
199
+ done: 0,
200
+ total: paths.length,
201
+ ok: 0,
202
+ failed: 0,
203
+ startedAt: started,
204
+ finishedAt: null,
205
+ entries: [],
206
+ });
207
+
208
+ let ok = 0;
209
+ let failed = 0;
210
+ let recovered = 0;
211
+ try {
212
+ /** @type {string[]} */
213
+ let failedPaths;
214
+ ({ ok, failed, failedPaths } = await crawl(origin, paths, concurrency));
215
+
216
+ // Hatalar çoğunlukla upstream rate limit'i (429): ilk tur yüzlerce sayfayı
217
+ // aynı anda çekerken API'yi zorluyor. Tek seri tekrar turu bu sayfaların
218
+ // önbelleğe girmesini sağlıyor; aksi hâlde ziyaretçi soğuk render'ı öder.
219
+ if (failedPaths.length) {
220
+ const firstOk = ok;
221
+ 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
+ });
227
+ recovered = retry.ok;
228
+ ok += retry.ok;
229
+ failed -= retry.ok;
230
+ }
231
+ } finally {
232
+ prewarmProgress.active = false;
233
+ prewarmProgress.finishedAt = Date.now();
234
+ }
235
+ const elapsed = Date.now() - started;
236
+
237
+ if (!quiet && paths.length) {
238
+ const skipped = all.length - paths.length;
239
+ 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ışı` : ""}` +
244
+ ` (${(elapsed / 1000).toFixed(1)}s)`,
245
+ );
246
+ }
247
+
248
+ return { ok, failed, total: paths.length, elapsed };
249
+ }
250
+
251
+ /**
252
+ * Açılışta ısıtmayı tetikler. `listen` geri çağrısından çağrılır; isteğe
253
+ * bağlı olarak periyodik tekrarlar. Hiçbir hata süreci düşürmez.
254
+ *
255
+ * @param {{ port: number }} options
256
+ * @returns {void}
257
+ */
258
+ export function startPrewarm({ port }) {
259
+ const config = getConfig();
260
+ if (process.env.PREWARM === "0") return;
261
+ if (process.env.PREWARM !== "1" && config.prewarm?.enabled === false) return;
262
+ // Isıtacak yol bildirmeyen bir projede zamanlayıcı kurmanın anlamı yok.
263
+ if (typeof config.hooks?.prewarmPaths !== "function") return;
264
+
265
+ const isDev = process.env.NODE_ENV === "development";
266
+ 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
+ });
271
+
272
+ // Isıtma ilk isteklerle yarışmasın diye gecikmeyle başlar. Dev'de gecikme
273
+ // daha uzun: dosya kaydı süreci yeniden başlattığı için zamanlayıcı da
274
+ // ölür; yalnızca sunucu bir süre sakin kalınca ısınır.
275
+ const delay = setting("PREWARM_DELAY_MS", "delayMs", isDev ? 3000 : 500);
276
+ setTimeout(run, delay).unref();
277
+
278
+ // Girdiler `revalidate` ile yaşlanır; stale-while-revalidate sayesinde
279
+ // ziyaretçi beklemez. Periyodik tur, hiç ziyaret edilmeyen sayfaları da
280
+ // sıcak tutmak isteyen kurulumlar için opsiyoneldir.
281
+ const interval = setting("PREWARM_INTERVAL_SECONDS", "intervalSeconds", 0);
282
+ if (interval > 0) setInterval(run, interval * 1000).unref();
283
+ }
@@ -0,0 +1,356 @@
1
+ /**
2
+ * EJS render katmanı + HTML TTL cache (ISR ikamesi).
3
+ *
4
+ * Controller sözleşmesi:
5
+ * async (ctx) => { view, data?, metadata?, status?, revalidate?, head?,
6
+ * bodyClass?, entries? }
7
+ * `ctx` → { params, query, pathname, req }
8
+ *
9
+ * `route()` üç kapsamı belirli bir sırayla iç içe kurar:
10
+ * withHtmlCache( withUpstreamTracking( withRequestCache( controller ) ) )
11
+ * Sıra önemli — istek içi cache en içte olmalı ki aynı render'daki iki
12
+ * çağrı tek upstream isteğine düşsün; upstream takibi HTML cache'in içinde
13
+ * olmalı ki eksik veriyle üretilen çıktı önbelleğe yazılmasın.
14
+ */
15
+ import path from "node:path";
16
+ import process from "node:process";
17
+ import ejs from "ejs";
18
+ import { withHtmlCache } from "./html-cache.js";
19
+ import { getConfig, hook } from "../config/index.js";
20
+ import { matchPattern } from "../config/pattern.js";
21
+ import { encodeText, negotiateEncoding } from "./middleware/compression.js";
22
+ import { navigationHints, preconnectHints } from "./head-hints.js";
23
+ import { withRequestCache } from "../http/request-cache.js";
24
+ import { getUpstreamFailures, withUpstreamTracking } from "./upstream-tracking.js";
25
+ import { isNotFoundError, isRedirectError } from "../http/control-flow.js";
26
+ import { renderHeadMeta } from "./metadata.js";
27
+ import { asset, hasAsset } from "./assets.js";
28
+ import * as html from "../views/helpers/html.js";
29
+ import * as tags from "../views/helpers/tags.js";
30
+ import { loadComponents } from "../views/components/loader.js";
31
+ import { renderStatusPage } from "./status-page.js";
32
+
33
+ const isDev = process.env.NODE_ENV === "development";
34
+
35
+ /**
36
+ * Şablonlara otomatik geçen yardımcılar ve EJS ayarları. Bileşen taraması
37
+ * dosya sistemine dokunduğu için bir kez yapılır; config yüklenmeden
38
+ * hesaplanamaz, bu yüzden ilk render'da kurulur.
39
+ *
40
+ * @type {{ helpers: Record<string, unknown>, options: ejs.Options,
41
+ * viewsDir: string, layout: string } | null}
42
+ */
43
+ let engine = null;
44
+
45
+ /**
46
+ * @returns {Promise<NonNullable<typeof engine>>}
47
+ */
48
+ async function getEngine() {
49
+ if (engine) return engine;
50
+
51
+ const config = getConfig();
52
+ const viewsDir = config.dirs.views;
53
+
54
+ engine = {
55
+ viewsDir,
56
+ layout: config.layout,
57
+ helpers: {
58
+ ...html,
59
+ ...tags,
60
+ ...(await loadComponents(path.join(viewsDir, "components"))),
61
+ asset,
62
+ hasAsset,
63
+ },
64
+ options: {
65
+ // `include('partials/header')` gibi çağrılar views kökünden çözülür.
66
+ root: viewsDir,
67
+ views: [viewsDir],
68
+ cache: !isDev,
69
+ rmWhitespace: true,
70
+ async: true,
71
+ },
72
+ };
73
+
74
+ return engine;
75
+ }
76
+
77
+ /**
78
+ * Uygulama geliştirirken bileşen dosyaları değişince kayıt yenilenmeli.
79
+ * Dev sunucusu süreci yeniden başlattığı için normalde gerekmez; gömülü
80
+ * kullanımlar (test, script) için dışa açık.
81
+ *
82
+ * @returns {void}
83
+ */
84
+ export function resetRenderEngine() {
85
+ engine = null;
86
+ }
87
+
88
+ /**
89
+ * Layout kullanmadan tek bir şablon render eder. Fragment/partial uçları
90
+ * ve e-posta şablonları bunu kullanır.
91
+ *
92
+ * @param {string} view `views/` altındaki yol, uzantısız (örn. "pages/home")
93
+ * @param {object} [data]
94
+ * @returns {Promise<string>}
95
+ */
96
+ export async function renderView(view, data = {}) {
97
+ const { viewsDir, helpers, options } = await getEngine();
98
+ const file = path.join(viewsDir, `${view}.ejs`);
99
+ return ejs.renderFile(file, { ...helpers, ...data }, options);
100
+ }
101
+
102
+ /**
103
+ * Sayfayı layout içinde render eder.
104
+ *
105
+ * @param {{ view: string, data?: object, metadata?: object, head?: string,
106
+ * bodyClass?: string, entries?: string[], pathname?: string }} page
107
+ * @returns {Promise<string>}
108
+ */
109
+ export async function renderPage(page) {
110
+ const { helpers, options, layout } = await getEngine();
111
+ const config = getConfig();
112
+
113
+ const metadata = {
114
+ ...(await hook("metadata", {}, page)),
115
+ ...(page.metadata ?? {}),
116
+ };
117
+
118
+ // Layout bağlamı ve gövde paralel üretilir: navigasyon çoğu projede
119
+ // upstream'den geliyor ve gövde render'ıyla sırayla beklemek her sayfaya
120
+ // gereksiz gecikme ekliyor.
121
+ const [body, context] = await Promise.all([
122
+ renderView(page.view, { ...(page.data ?? {}), metadata }),
123
+ hook("layoutContext", {}, { pathname: page.pathname ?? "", metadata }),
124
+ ]);
125
+
126
+ return ejs.renderFile(
127
+ layout,
128
+ {
129
+ ...helpers,
130
+ ...context,
131
+ metadata,
132
+ // Boş varsayılan bilinçli: "/" yazmak her sayfayı ana sayfa sanıp
133
+ // logoyu <h1> olarak bastıran türde hatalara yol açıyor.
134
+ pathname: page.pathname ?? "",
135
+ lang: context.lang ?? config.brand.lang ?? "en",
136
+ headMeta: renderHeadMeta(metadata),
137
+ structuredData: context.structuredData ?? [],
138
+ // Preconnect her sayfada aynı; LCP preload'ını sayfa kendisi ekler.
139
+ // Gezinme ipuçları preconnect'ten sonra: spekülasyon bir sonraki sayfayı
140
+ // ilgilendiriyor, bu sayfanın LCP'sinin önüne geçmemeli.
141
+ extraHead:
142
+ preconnectHints() +
143
+ navigationHints() +
144
+ (page.head ?? "") +
145
+ (context.extraHead ?? ""),
146
+ bodyClass: page.bodyClass ?? context.bodyClass ?? "",
147
+ entries: page.entries ?? [],
148
+ devtools: isDev,
149
+ devBasePath: config.brand.devBasePath,
150
+ body,
151
+ },
152
+ options,
153
+ );
154
+ }
155
+
156
+ /**
157
+ * Controller'ı çalıştırıp yanıtı yazar; notFound/redirect kontrol akışını,
158
+ * HTML cache'ini ve hata yönetimini üstlenir.
159
+ *
160
+ * @param {(ctx: { params: object, query: object, pathname: string,
161
+ * req: import('express').Request }) => Promise<object>} controller
162
+ * @param {{ revalidate?: number }} [options]
163
+ * @returns {import('express').RequestHandler}
164
+ */
165
+ export function route(controller, options = {}) {
166
+ return async (req, res, next) => {
167
+ const ctx = {
168
+ params: req.params ?? {},
169
+ query: req.query ?? {},
170
+ pathname: req.path,
171
+ req,
172
+ };
173
+
174
+ const revalidate = resolveRevalidate(req.path, options.revalidate);
175
+ const cacheable = req.method === "GET" && Boolean(revalidate);
176
+ const cacheKey = `${req.path}?${new URLSearchParams(
177
+ Object.entries(ctx.query).map(([k, v]) => [k, String(v)]),
178
+ ).toString()}`;
179
+
180
+ try {
181
+ const result = await withHtmlCache(cacheKey, cacheable ? revalidate : 0, () =>
182
+ withUpstreamTracking(() => withRequestCache(() => produce(controller, ctx))),
183
+ );
184
+
185
+ res.status(result.status);
186
+ res.setHeader("Content-Type", "text/html; charset=utf-8");
187
+ if (cacheable) {
188
+ res.setHeader(
189
+ "Cache-Control",
190
+ `public, max-age=0, s-maxage=${revalidate}, stale-while-revalidate=60`,
191
+ );
192
+ }
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);
198
+ } catch (error) {
199
+ if (isRedirectError(error)) {
200
+ res.redirect(error.statusCode, error.location);
201
+ return;
202
+ }
203
+ next(error);
204
+ }
205
+ };
206
+ }
207
+
208
+ /**
209
+ * `jskelet.config.mjs` → `cache().html` route'un kendi `revalidate`'ini ezer.
210
+ * Sonuç yol başına hatırlanır: her istekte desen taraması yapılmaz.
211
+ *
212
+ * @type {Map<string, number | undefined>}
213
+ */
214
+ const revalidateByPath = new Map();
215
+
216
+ /**
217
+ * Yakalayıcı bir route'ta (`/:slug`) her benzersiz yol burada kalıcı bir girdi
218
+ * bırakıyor; sınır olmadan bu, uzun ömürlü süreçte bellek sızıntısına dönüşür.
219
+ * Desen taraması ucuz olduğu için en eski girdileri atmak güvenli.
220
+ */
221
+ const REVALIDATE_CACHE_MAX = 2000;
222
+
223
+ /**
224
+ * @param {string} pathname
225
+ * @param {number | undefined} fallback
226
+ * @returns {number | undefined}
227
+ */
228
+ function resolveRevalidate(pathname, fallback) {
229
+ const rules = getConfig().html;
230
+ if (!rules.length) return fallback;
231
+
232
+ if (revalidateByPath.has(pathname)) {
233
+ return revalidateByPath.get(pathname) ?? fallback;
234
+ }
235
+
236
+ const match = rules.find((rule) => matchPattern(rule.pattern, pathname));
237
+
238
+ if (revalidateByPath.size >= REVALIDATE_CACHE_MAX) {
239
+ const oldest = revalidateByPath.keys().next().value;
240
+ if (oldest !== undefined) revalidateByPath.delete(oldest);
241
+ }
242
+
243
+ revalidateByPath.set(pathname, match?.seconds);
244
+ return match ? match.seconds : fallback;
245
+ }
246
+
247
+ /**
248
+ * Cache'lenmiş bir sayfa aynı gövdeyi her istekte yeniden sıkıştırmasın diye
249
+ * brotli/gzip çıktısı HTML ile birlikte saklanır. `Content-Encoding` burada
250
+ * ayarlandığı için sıkıştırma middleware'i devreye girmez.
251
+ *
252
+ * @param {import('express').Request} req
253
+ * @param {import('express').Response} res
254
+ * @param {string} body
255
+ * @param {Map<string, Buffer>} [encoded]
256
+ * @returns {Promise<void>}
257
+ */
258
+ async function sendHtml(req, res, body, encoded) {
259
+ const encoding =
260
+ req.method === "HEAD" ? null : negotiateEncoding(req.headers["accept-encoding"]);
261
+
262
+ if (!encoding || !encoded) {
263
+ res.send(body);
264
+ return;
265
+ }
266
+
267
+ let buffer = encoded.get(encoding);
268
+ if (!buffer) {
269
+ buffer = await encodeText(body, encoding);
270
+ encoded.set(encoding, buffer);
271
+ }
272
+
273
+ res.setHeader("Content-Encoding", encoding);
274
+ res.setHeader("Vary", "Accept-Encoding");
275
+ res.setHeader("Content-Length", String(buffer.length));
276
+ res.end(buffer);
277
+ }
278
+
279
+ /**
280
+ * @param {Function} controller
281
+ * @param {{ pathname: string }} ctx
282
+ * @returns {Promise<{ html: string, status: number, degraded?: boolean }>}
283
+ */
284
+ async function produce(controller, ctx) {
285
+ try {
286
+ const page = await controller(ctx);
287
+ const rendered = await renderPage({ pathname: ctx.pathname, ...page });
288
+ return {
289
+ html: rendered,
290
+ status: page.status ?? 200,
291
+ degraded: hasUpstreamFailures(ctx.pathname),
292
+ };
293
+ } catch (error) {
294
+ if (isNotFoundError(error)) {
295
+ return { html: await renderNotFound(), status: 404 };
296
+ }
297
+ throw error;
298
+ }
299
+ }
300
+
301
+ /** Ağ hatası (0) ve geçici olduğu varsayılan durumlar. */
302
+ const TRANSIENT_STATUSES = new Set([0, 408, 425, 429]);
303
+
304
+ /** @param {number} status */
305
+ function isTransient(status) {
306
+ return TRANSIENT_STATUSES.has(status) || status >= 500;
307
+ }
308
+
309
+ /**
310
+ * Render sırasında upstream düştüyse çıktı eksik veri içeriyor demektir.
311
+ * Böyle bir HTML önbelleğe yazılmaz: sonraki istek yeniden dener.
312
+ *
313
+ * Ancak yalnızca *geçici* hatalar için. 400/403/404 gibi deterministik
314
+ * cevaplar tekrar denemekle düzelmez; onlar yüzünden önbelleği kapatmak
315
+ * sayfayı her ziyarette baştan render etmek olur — içerik yine aynı eksik
316
+ * hâliyle döner, ziyaretçi sadece render süresini öder. Bu yüzden kalıcı
317
+ * hatalar yalnızca loglanır, önbelleği engellemez.
318
+ *
319
+ * @param {string} pathname
320
+ * @returns {boolean}
321
+ */
322
+ function hasUpstreamFailures(pathname) {
323
+ const failures = getUpstreamFailures();
324
+ if (!failures.length) return false;
325
+
326
+ /** @param {typeof failures} list */
327
+ const summarize = (list) =>
328
+ list.map((failure) => `${failure.status} ${failure.path}`).join(", ");
329
+
330
+ const transient = failures.filter((failure) => isTransient(failure.status));
331
+ const permanent = failures.filter((failure) => !isTransient(failure.status));
332
+
333
+ if (permanent.length) {
334
+ console.warn(
335
+ `[render] ${pathname} eksik veriyle üretildi, upstream kalıcı hata veriyor (${summarize(permanent)})`,
336
+ );
337
+ }
338
+
339
+ if (!transient.length) return false;
340
+
341
+ console.warn(
342
+ `[render] ${pathname} eksik veriyle üretildi, önbelleğe alınmıyor (${summarize(transient)})`,
343
+ );
344
+
345
+ return true;
346
+ }
347
+
348
+ /**
349
+ * 404 sayfası. `renderStatusPage(404)` için kısayol; route dosyalarında en sık
350
+ * ihtiyaç duyulan durum bu olduğu için ayrı bir ad taşımaya devam ediyor.
351
+ *
352
+ * @returns {Promise<string>}
353
+ */
354
+ export async function renderNotFound() {
355
+ return renderStatusPage(404);
356
+ }