jskelet 0.5.1 → 0.5.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/CHANGELOG.md +19 -0
- package/docs/03-routing.md +4 -0
- package/docs/04-render-ve-sablonlar.md +58 -0
- package/docs/06-cache.md +62 -11
- package/docs/07-yapilandirma.md +29 -2
- package/docs/11-tasima.md +2 -0
- package/docs/README.md +3 -2
- package/docs/en/03-routing.md +4 -0
- package/docs/en/04-rendering.md +58 -0
- package/docs/en/06-caching.md +63 -12
- package/docs/en/07-configuration.md +30 -2
- package/docs/en/11-migration.md +2 -0
- package/docs/en/README.md +2 -2
- package/package.json +1 -1
- package/src/config/defaults.js +8 -0
- package/src/config/index.js +49 -2
- package/src/index.js +10 -0
- package/src/server/cache-vary.js +113 -0
- package/src/server/html-cache.js +17 -7
- package/src/server/og-image.js +356 -0
- package/src/server/prewarm.js +78 -10
- package/src/server/render.js +16 -7
- package/src/server/router.js +8 -2
package/src/server/prewarm.js
CHANGED
|
@@ -24,6 +24,65 @@ import { getDataCacheStats } from "./data-cache.js";
|
|
|
24
24
|
import { isTransientStatus } from "./upstream-tracking.js";
|
|
25
25
|
import { upstreamCooldownMs } from "./upstream-limiter.js";
|
|
26
26
|
|
|
27
|
+
/**
|
|
28
|
+
* `cache().prewarm.origins` yoksa loopback. Port'suz origin'lere dinleme
|
|
29
|
+
* portu eklenir — `http://tr.localhost` → `http://tr.localhost:3000`.
|
|
30
|
+
*
|
|
31
|
+
* @param {number} port
|
|
32
|
+
* @returns {string[]}
|
|
33
|
+
*/
|
|
34
|
+
function resolvePrewarmOrigins(port) {
|
|
35
|
+
const configured = getConfig().prewarm?.origins;
|
|
36
|
+
const list = Array.isArray(configured)
|
|
37
|
+
? configured.filter((value) => typeof value === "string" && value)
|
|
38
|
+
: [];
|
|
39
|
+
|
|
40
|
+
if (!list.length) return [`http://127.0.0.1:${port}`];
|
|
41
|
+
|
|
42
|
+
return list.map((origin) => {
|
|
43
|
+
try {
|
|
44
|
+
const url = new URL(origin);
|
|
45
|
+
if (!url.port) url.port = String(port);
|
|
46
|
+
return url.origin;
|
|
47
|
+
} catch {
|
|
48
|
+
return origin;
|
|
49
|
+
}
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Vary açıkken ısıtma isteği ziyaretçinin Host'unu taşısın — aksi halde
|
|
55
|
+
* loopback anahtarı ısınır, gerçek locale host soğuk kalır.
|
|
56
|
+
*
|
|
57
|
+
* @param {{ get?: (name: string) => string | undefined,
|
|
58
|
+
* headers?: Record<string, unknown>, protocol?: string } | undefined} req
|
|
59
|
+
* @param {string} fallback
|
|
60
|
+
* @returns {string}
|
|
61
|
+
*/
|
|
62
|
+
function originFromRequest(req, fallback) {
|
|
63
|
+
if (!req) return fallback;
|
|
64
|
+
|
|
65
|
+
let vary;
|
|
66
|
+
try {
|
|
67
|
+
vary = getConfig().cacheVary;
|
|
68
|
+
} catch {
|
|
69
|
+
return fallback;
|
|
70
|
+
}
|
|
71
|
+
if (!vary?.host && !vary?.headers?.length && !vary?.fn) return fallback;
|
|
72
|
+
|
|
73
|
+
const host =
|
|
74
|
+
req.get?.("host") ||
|
|
75
|
+
(typeof req.headers?.host === "string" ? req.headers.host : "");
|
|
76
|
+
if (!host) return fallback;
|
|
77
|
+
|
|
78
|
+
const forwarded = req.headers?.["x-forwarded-proto"];
|
|
79
|
+
const protoRaw = Array.isArray(forwarded)
|
|
80
|
+
? forwarded[0]
|
|
81
|
+
: forwarded || req.protocol || "http";
|
|
82
|
+
const proto = String(protoRaw).split(",")[0].trim() || "http";
|
|
83
|
+
return `${proto}://${host}`;
|
|
84
|
+
}
|
|
85
|
+
|
|
27
86
|
/** Klasik turu yöneten env'ler; `onVisit` ile birlikte yasak. */
|
|
28
87
|
const CLASSIC_PREWARM_ENV = [
|
|
29
88
|
"PREWARM_MAX",
|
|
@@ -715,6 +774,9 @@ export function noteVisitWarm(html, context) {
|
|
|
715
774
|
/** @type {string | undefined} */ (context.req?.headers?.["user-agent"]);
|
|
716
775
|
if (ua && ua === getConfig().brand.prewarmUserAgent) return;
|
|
717
776
|
|
|
777
|
+
// Vary açıksa bu ziyaretin Host'u üzerinden ısıt — loopback anahtarı değil.
|
|
778
|
+
visitOrigin = originFromRequest(context.req, visitOrigin);
|
|
779
|
+
|
|
718
780
|
const perPage = Number(getConfig().prewarm?.onVisit?.perPage) || 20;
|
|
719
781
|
const links = extractSameOriginLinks(html, {
|
|
720
782
|
limit: perPage,
|
|
@@ -796,12 +858,13 @@ export function startPrewarm({ port }) {
|
|
|
796
858
|
const config = getConfig();
|
|
797
859
|
if (process.env.PREWARM === "0") return;
|
|
798
860
|
|
|
799
|
-
const
|
|
861
|
+
const origins = resolvePrewarmOrigins(port);
|
|
862
|
+
const origin = origins[0];
|
|
800
863
|
|
|
801
864
|
// Klasik `prewarmPaths` olmasa da TTL öncesi soft-bayatlayan girdiler
|
|
802
865
|
// HTTP ile ısıtılsın. `PREWARM=0` yukarıda her şeyi keser.
|
|
803
866
|
startEarlyExpirySweep();
|
|
804
|
-
startExpiryWarmDrain(
|
|
867
|
+
startExpiryWarmDrain(origins);
|
|
805
868
|
|
|
806
869
|
if (config.prewarm?.onVisit?.enabled) {
|
|
807
870
|
const classicEnv = CLASSIC_PREWARM_ENV.filter((key) => process.env[key]);
|
|
@@ -833,7 +896,10 @@ export function startPrewarm({ port }) {
|
|
|
833
896
|
if (running) return;
|
|
834
897
|
running = true;
|
|
835
898
|
try {
|
|
836
|
-
|
|
899
|
+
// `vary.host` açıkken her origin ayrı anahtar ısıtır.
|
|
900
|
+
for (const next of origins) {
|
|
901
|
+
await prewarm({ origin: next });
|
|
902
|
+
}
|
|
837
903
|
} catch (error) {
|
|
838
904
|
console.error("[prewarm] failed", error);
|
|
839
905
|
} finally {
|
|
@@ -856,8 +922,8 @@ export function startPrewarm({ port }) {
|
|
|
856
922
|
if (interval > 0) setInterval(() => void run(), interval * 1000).unref();
|
|
857
923
|
}
|
|
858
924
|
|
|
859
|
-
/** @type {string
|
|
860
|
-
let
|
|
925
|
+
/** @type {string[]} */
|
|
926
|
+
let expiryOrigins = [];
|
|
861
927
|
|
|
862
928
|
/** @type {boolean} */
|
|
863
929
|
let expiryDraining = false;
|
|
@@ -869,11 +935,11 @@ let expiryDrainTimer = null;
|
|
|
869
935
|
* Soft-bayat / invalidate kuyruğunu periyodik boşaltır. Klasik tur ve onVisit
|
|
870
936
|
* aynı kuyruğu da okur; bu drain `prewarmPaths` yokken de çalışır.
|
|
871
937
|
*
|
|
872
|
-
* @param {string}
|
|
938
|
+
* @param {string[]} origins
|
|
873
939
|
* @returns {void}
|
|
874
940
|
*/
|
|
875
|
-
function startExpiryWarmDrain(
|
|
876
|
-
|
|
941
|
+
function startExpiryWarmDrain(origins) {
|
|
942
|
+
expiryOrigins = origins.length ? origins : [];
|
|
877
943
|
if (expiryDrainTimer) return;
|
|
878
944
|
expiryDrainTimer = setInterval(() => {
|
|
879
945
|
void drainExpiryWarm();
|
|
@@ -885,7 +951,7 @@ function startExpiryWarmDrain(origin) {
|
|
|
885
951
|
* @returns {Promise<void>}
|
|
886
952
|
*/
|
|
887
953
|
async function drainExpiryWarm() {
|
|
888
|
-
if (expiryDraining || !
|
|
954
|
+
if (expiryDraining || !expiryOrigins.length) return;
|
|
889
955
|
|
|
890
956
|
const paths = takeInvalidatedPaths();
|
|
891
957
|
if (!paths.length) return;
|
|
@@ -895,7 +961,9 @@ async function drainExpiryWarm() {
|
|
|
895
961
|
const cold = paths.filter((path) => !isHtmlCacheFresh(path));
|
|
896
962
|
if (!cold.length) return;
|
|
897
963
|
|
|
898
|
-
|
|
964
|
+
for (const origin of expiryOrigins) {
|
|
965
|
+
await prewarm({ origin, paths: cold, quiet: true });
|
|
966
|
+
}
|
|
899
967
|
} catch (error) {
|
|
900
968
|
console.error("[prewarm] expiry warm failed", error);
|
|
901
969
|
} finally {
|
package/src/server/render.js
CHANGED
|
@@ -18,6 +18,7 @@ import fs from "node:fs";
|
|
|
18
18
|
import { pathToFileURL } from "node:url";
|
|
19
19
|
import ejs from "ejs";
|
|
20
20
|
import { withHtmlCache } from "./html-cache.js";
|
|
21
|
+
import { buildVaryPrefix } from "./cache-vary.js";
|
|
21
22
|
import { getConfig, hook } from "../config/index.js";
|
|
22
23
|
import { matchPattern } from "../config/pattern.js";
|
|
23
24
|
import { encodeText, negotiateEncoding } from "./middleware/compression.js";
|
|
@@ -329,12 +330,14 @@ export function route(controller, options = {}) {
|
|
|
329
330
|
// Anahtar `null` ise query bu yol için cache'lenebilir değil: sayfa
|
|
330
331
|
// dinamik davranır. Anahtar yine de gerekiyor (hata sayfası ölçümü,
|
|
331
332
|
// teşhis) ama TTL sıfırlanıp cache yolu kapatılır.
|
|
332
|
-
const key = buildCacheKey(req.path, ctx.query);
|
|
333
|
+
const key = buildCacheKey(req.path, ctx.query, req);
|
|
333
334
|
const cacheable =
|
|
334
335
|
!isPrivate && req.method === "GET" && Boolean(revalidate) && key !== null;
|
|
335
|
-
const cacheKey =
|
|
336
|
-
|
|
337
|
-
|
|
336
|
+
const cacheKey =
|
|
337
|
+
key ??
|
|
338
|
+
`${buildVaryPrefix(req)}${req.path}?${new URLSearchParams(
|
|
339
|
+
Object.entries(ctx.query).map(([k, v]) => [k, String(v)]),
|
|
340
|
+
).toString()}`;
|
|
338
341
|
|
|
339
342
|
try {
|
|
340
343
|
const result = await withRequestContext(context, () =>
|
|
@@ -582,6 +585,10 @@ function resolveQueryPolicy(pathname) {
|
|
|
582
585
|
/**
|
|
583
586
|
* HTML cache anahtarı, ya da query bu yol için cache'lenebilir değilse `null`.
|
|
584
587
|
*
|
|
588
|
+
* Biçim: `${varyPrefix}${pathname}?${izin verilen query}`.
|
|
589
|
+
* Vary (`cache().vary`) query allowlist'ten bağımsız; host/locale sitelerinde
|
|
590
|
+
* ilk host'un HTML'inin diğerine servis edilmesini engeller.
|
|
591
|
+
*
|
|
585
592
|
* Varsayılan olarak query parametresi taşıyan istek dinamiktir: `cache().query`
|
|
586
593
|
* altında eşleşen bir kural olmadıkça cache'e hiç girmez. Aksi hâlde bir yolun
|
|
587
594
|
* bütün `?utm_source=…` varyantları ayrı girdi olur ve LRU'daki gerçek
|
|
@@ -592,11 +599,13 @@ function resolveQueryPolicy(pathname) {
|
|
|
592
599
|
*
|
|
593
600
|
* @param {string} pathname
|
|
594
601
|
* @param {Record<string, unknown>} query
|
|
602
|
+
* @param {{ headers?: Record<string, unknown>, get?: (name: string) => string | undefined }} [req]
|
|
595
603
|
* @returns {string | null}
|
|
596
604
|
*/
|
|
597
|
-
function buildCacheKey(pathname, query) {
|
|
605
|
+
function buildCacheKey(pathname, query, req) {
|
|
606
|
+
const vary = buildVaryPrefix(req);
|
|
598
607
|
const entries = Object.entries(query);
|
|
599
|
-
if (!entries.length) return `${pathname}?`;
|
|
608
|
+
if (!entries.length) return `${vary}${pathname}?`;
|
|
600
609
|
|
|
601
610
|
const policy = resolveQueryPolicy(pathname);
|
|
602
611
|
if (policy === null) return null;
|
|
@@ -612,7 +621,7 @@ function buildCacheKey(pathname, query) {
|
|
|
612
621
|
.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)),
|
|
613
622
|
);
|
|
614
623
|
|
|
615
|
-
return `${pathname}?${params.toString()}`;
|
|
624
|
+
return `${vary}${pathname}?${params.toString()}`;
|
|
616
625
|
}
|
|
617
626
|
|
|
618
627
|
/**
|
package/src/server/router.js
CHANGED
|
@@ -14,8 +14,9 @@
|
|
|
14
14
|
*
|
|
15
15
|
* Modül sözleşmesi: default export ya da `register` adlı named export,
|
|
16
16
|
* `(app, api) => void | Promise<void>` imzasıyla. `api` içinde `route`,
|
|
17
|
-
* `fragment`, `renderView`, `renderPage
|
|
18
|
-
* böylece route dosyaları framework'ten tek tek
|
|
17
|
+
* `fragment`, `renderView`, `renderPage`, `notFound`/`redirect` ve
|
|
18
|
+
* `ogHandler` hazır gelir, böylece route dosyaları framework'ten tek tek
|
|
19
|
+
* import yapmak zorunda kalmaz.
|
|
19
20
|
*/
|
|
20
21
|
import fs from "node:fs";
|
|
21
22
|
import path from "node:path";
|
|
@@ -29,6 +30,7 @@ import {
|
|
|
29
30
|
redirect,
|
|
30
31
|
seeOther,
|
|
31
32
|
} from "../http/control-flow.js";
|
|
33
|
+
import { ImageResponse, ogHandler, ogImage, sendOgImage } from "./og-image.js";
|
|
32
34
|
|
|
33
35
|
const isDev = process.env.NODE_ENV === "development";
|
|
34
36
|
|
|
@@ -96,6 +98,10 @@ const api = {
|
|
|
96
98
|
redirect,
|
|
97
99
|
permanentRedirect,
|
|
98
100
|
seeOther,
|
|
101
|
+
ogHandler,
|
|
102
|
+
ogImage,
|
|
103
|
+
sendOgImage,
|
|
104
|
+
ImageResponse,
|
|
99
105
|
};
|
|
100
106
|
|
|
101
107
|
/**
|