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.
@@ -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 origin = `http://127.0.0.1:${port}`;
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(origin);
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
- await prewarm({ origin });
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 | null} */
860
- let expiryOrigin = null;
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} origin
938
+ * @param {string[]} origins
873
939
  * @returns {void}
874
940
  */
875
- function startExpiryWarmDrain(origin) {
876
- expiryOrigin = origin;
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 || !expiryOrigin) return;
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
- await prewarm({ origin: expiryOrigin, paths: cold, quiet: true });
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 {
@@ -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 = key ?? `${req.path}?${new URLSearchParams(
336
- Object.entries(ctx.query).map(([k, v]) => [k, String(v)]),
337
- ).toString()}`;
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
  /**
@@ -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` ve `notFound`/`redirect` hazır gelir,
18
- * böylece route dosyaları framework'ten tek tek import yapmak zorunda kalmaz.
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
  /**