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.
- package/AGENTS.md +127 -0
- package/CHANGELOG.md +40 -0
- package/LICENSE +21 -0
- package/README.md +342 -0
- package/bin/jskelet.mjs +104 -0
- package/docs/01-baslangic.md +285 -0
- package/docs/02-mimari.md +287 -0
- package/docs/03-routing.md +437 -0
- package/docs/04-render-ve-sablonlar.md +490 -0
- package/docs/05-islands.md +429 -0
- package/docs/06-cache.md +409 -0
- package/docs/07-yapilandirma.md +673 -0
- package/docs/08-build.md +366 -0
- package/docs/09-dev-araclari.md +302 -0
- package/docs/10-dagitim.md +329 -0
- package/docs/11-tasima.md +352 -0
- package/docs/README.md +82 -0
- package/package.json +97 -0
- package/src/build/build.mjs +138 -0
- package/src/build/ensure-build.mjs +15 -0
- package/src/build/paths.mjs +118 -0
- package/src/build/resolve-peer.mjs +36 -0
- package/src/build/tasks/client.mjs +268 -0
- package/src/build/tasks/css.mjs +124 -0
- package/src/build/tasks/fonts.mjs +146 -0
- package/src/build/tasks/icons.mjs +224 -0
- package/src/build/tasks/images.mjs +244 -0
- package/src/build/tasks/precompress.mjs +78 -0
- package/src/client/devtools/overlay.js +1763 -0
- package/src/client/devtools/report.html +185 -0
- package/src/client/devtools/report.js +712 -0
- package/src/client/dom.js +95 -0
- package/src/client/index.js +26 -0
- package/src/client/registry.js +223 -0
- package/src/client/safe-image.js +91 -0
- package/src/client/store.js +36 -0
- package/src/config/defaults.js +102 -0
- package/src/config/index.js +433 -0
- package/src/config/pattern.js +107 -0
- package/src/dev-server.mjs +383 -0
- package/src/http/control-flow.js +56 -0
- package/src/http/request-cache.js +46 -0
- package/src/index.js +35 -0
- package/src/init.mjs +220 -0
- package/src/log.mjs +332 -0
- package/src/logo.png +0 -0
- package/src/runtime/alias-hooks.mjs +119 -0
- package/src/runtime/register.mjs +4 -0
- package/src/server/assets.js +119 -0
- package/src/server/create-app.js +167 -0
- package/src/server/dev/devtools.js +383 -0
- package/src/server/dev/report.js +351 -0
- package/src/server/head-hints.js +132 -0
- package/src/server/html-cache.js +166 -0
- package/src/server/metadata.js +102 -0
- package/src/server/middleware/compression.js +205 -0
- package/src/server/middleware/dev-gate.js +62 -0
- package/src/server/middleware/headers.js +37 -0
- package/src/server/middleware/redirects.js +32 -0
- package/src/server/middleware/static-precompressed.js +100 -0
- package/src/server/middleware/upstream-proxy.js +141 -0
- package/src/server/prewarm.js +283 -0
- package/src/server/render.js +356 -0
- package/src/server/router.js +121 -0
- package/src/server/status-page.js +164 -0
- package/src/server/upstream-tracking.js +51 -0
- package/src/start.mjs +7 -0
- package/src/templates/layout.ejs +44 -0
- package/src/version.mjs +17 -0
- package/src/views/components/loader.js +85 -0
- package/src/views/helpers/html.js +102 -0
- 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
|
+
}
|