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