jskelet 0.5.5 → 0.6.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 (156) hide show
  1. package/AGENTS.md +19 -15
  2. package/CHANGELOG.md +165 -15
  3. package/README.md +16 -21
  4. package/bin/jskelet.mjs +23 -9
  5. package/docs/01-baslangic.md +4 -3
  6. package/docs/02-mimari.md +10 -4
  7. package/docs/03-routing.md +14 -7
  8. package/docs/04-render-ve-sablonlar.md +60 -43
  9. package/docs/05-islands.md +12 -8
  10. package/docs/06-cache.md +18 -7
  11. package/docs/07-yapilandirma.md +69 -27
  12. package/docs/08-build.md +15 -9
  13. package/docs/09-dev-araclari.md +22 -8
  14. package/docs/10-dagitim.md +14 -13
  15. package/docs/11-tasima.md +51 -17
  16. package/docs/12-panel-ve-oturum.md +10 -4
  17. package/docs/README.md +10 -33
  18. package/docs/en/01-getting-started.md +4 -3
  19. package/docs/en/02-architecture.md +12 -6
  20. package/docs/en/03-routing.md +15 -8
  21. package/docs/en/04-rendering.md +71 -59
  22. package/docs/en/05-islands.md +13 -8
  23. package/docs/en/06-caching.md +21 -7
  24. package/docs/en/07-configuration.md +69 -29
  25. package/docs/en/08-build.md +16 -10
  26. package/docs/en/09-dev-tools.md +24 -8
  27. package/docs/en/10-deployment.md +14 -14
  28. package/docs/en/11-migration.md +51 -16
  29. package/docs/en/12-dashboards-and-sessions.md +9 -4
  30. package/docs/en/README.md +10 -35
  31. package/package.json +48 -13
  32. package/src/build/tasks/client.mjs +91 -10
  33. package/src/build/tasks/icons.mjs +11 -1
  34. package/src/client/index.js +2 -2
  35. package/src/compile/codegen.js +4 -0
  36. package/src/compile/compile-all.js +12 -21
  37. package/src/compile/expr.js +5 -0
  38. package/src/compile/parse.js +64 -8
  39. package/src/compile/resolve.js +3 -0
  40. package/src/config/defaults.js +48 -5
  41. package/src/config/index.js +138 -27
  42. package/src/dev-server.mjs +26 -3
  43. package/src/http/cookies-entry.js +1 -0
  44. package/src/http/cookies.js +18 -0
  45. package/src/logo.png +0 -0
  46. package/src/migrate/apply.mjs +262 -0
  47. package/src/migrate/babel.mjs +79 -0
  48. package/src/migrate/classify.mjs +155 -0
  49. package/src/migrate/config.mjs +126 -0
  50. package/src/migrate/fs-walk.mjs +191 -0
  51. package/src/migrate/parse.mjs +26 -0
  52. package/src/migrate/scan.mjs +177 -0
  53. package/src/migrate/transform/expr-source.mjs +168 -0
  54. package/src/migrate/transform/island.mjs +67 -0
  55. package/src/migrate/transform/jsx-to-component.mjs +302 -0
  56. package/src/migrate/transform/jsx-to-jsk.mjs +330 -0
  57. package/src/migrate/transform/page-split.mjs +435 -0
  58. package/src/migrate/write.mjs +81 -0
  59. package/src/migrate.mjs +171 -0
  60. package/src/server/auth/handoff.js +94 -11
  61. package/src/server/create-app.js +37 -10
  62. package/src/server/ejs-adapter.js +59 -0
  63. package/src/server/html-cache.js +178 -32
  64. package/src/server/image-optimizer.js +94 -26
  65. package/src/server/middleware/dev-gate.js +21 -8
  66. package/src/server/middleware/robots-txt.js +341 -0
  67. package/src/server/port-guard.js +255 -0
  68. package/src/server/prewarm.js +137 -51
  69. package/src/server/render.js +30 -10
  70. package/src/server/status-page.js +105 -4
  71. package/src/start.mjs +18 -3
  72. package/src/templates/layout.ejs +8 -28
  73. package/src/templates/layout.jsk +30 -0
  74. package/src/templates/layout.render.js +41 -0
  75. package/src/views/helpers/tags.js +86 -3
  76. package/types/build/resolve-peer.d.mts +13 -0
  77. package/types/client/dom.d.ts +55 -0
  78. package/types/client/form.d.ts +19 -0
  79. package/types/client/index.d.ts +20 -0
  80. package/types/client/registry.d.ts +53 -0
  81. package/types/client/safe-image.d.ts +19 -0
  82. package/types/client/shared-cookie.d.ts +82 -0
  83. package/types/client/store.d.ts +18 -0
  84. package/types/client/swap.d.ts +46 -0
  85. package/types/compile/codegen.d.ts +32 -0
  86. package/types/compile/compile-all.d.ts +42 -0
  87. package/types/compile/errors.d.ts +30 -0
  88. package/types/compile/expr.d.ts +67 -0
  89. package/types/compile/index.d.ts +10 -0
  90. package/types/compile/parse.d.ts +82 -0
  91. package/types/compile/resolve.d.ts +46 -0
  92. package/types/compile/scan-exports.d.ts +9 -0
  93. package/types/config/defaults.d.ts +477 -0
  94. package/types/config/index.d.ts +304 -0
  95. package/types/config/pattern.d.ts +38 -0
  96. package/types/http/control-flow.d.ts +45 -0
  97. package/types/http/cookies-entry.d.ts +5 -0
  98. package/types/http/cookies.d.ts +113 -0
  99. package/types/http/request-cache.d.ts +13 -0
  100. package/types/http/request-context.d.ts +67 -0
  101. package/types/http/shared-cookie.d.ts +73 -0
  102. package/types/index.d.ts +30 -0
  103. package/types/log.d.mts +153 -0
  104. package/types/server/admin/actions.d.ts +16 -0
  105. package/types/server/admin/auth.d.ts +52 -0
  106. package/types/server/admin/event-log.d.ts +38 -0
  107. package/types/server/admin/gate.d.ts +43 -0
  108. package/types/server/admin/inventory.d.ts +40 -0
  109. package/types/server/admin/mount.d.ts +6 -0
  110. package/types/server/admin/router.d.ts +6 -0
  111. package/types/server/admin/snapshot.d.ts +6 -0
  112. package/types/server/assets.d.ts +47 -0
  113. package/types/server/auth/handoff.d.ts +12 -0
  114. package/types/server/cache-deps.d.ts +16 -0
  115. package/types/server/cache-vary.d.ts +30 -0
  116. package/types/server/cloudflare.d.ts +163 -0
  117. package/types/server/create-app.d.ts +25 -0
  118. package/types/server/data-cache.d.ts +116 -0
  119. package/types/server/dev/devtools.d.ts +44 -0
  120. package/types/server/dev/report.d.ts +229 -0
  121. package/types/server/dev/socket.d.ts +17 -0
  122. package/types/server/dev/version-check.d.mts +15 -0
  123. package/types/server/ejs-adapter.d.ts +11 -0
  124. package/types/server/head-hints.d.ts +40 -0
  125. package/types/server/html-cache.d.ts +207 -0
  126. package/types/server/image-optimizer.d.ts +68 -0
  127. package/types/server/logs/access-middleware.d.ts +7 -0
  128. package/types/server/logs/file-sink.d.ts +17 -0
  129. package/types/server/logs/pipeline.d.ts +37 -0
  130. package/types/server/logs/s3-put.d.ts +85 -0
  131. package/types/server/logs/s3-sink.d.ts +26 -0
  132. package/types/server/metadata.d.ts +38 -0
  133. package/types/server/middleware/compression.d.ts +17 -0
  134. package/types/server/middleware/csrf.d.ts +4 -0
  135. package/types/server/middleware/dev-gate.d.ts +2 -0
  136. package/types/server/middleware/headers.d.ts +2 -0
  137. package/types/server/middleware/redirects.d.ts +2 -0
  138. package/types/server/middleware/robots-txt.d.ts +33 -0
  139. package/types/server/middleware/static-precompressed.d.ts +5 -0
  140. package/types/server/middleware/trailing-slash.d.ts +11 -0
  141. package/types/server/middleware/upstream-proxy.d.ts +21 -0
  142. package/types/server/og-image.d.ts +149 -0
  143. package/types/server/port-guard.d.ts +50 -0
  144. package/types/server/prewarm.d.ts +131 -0
  145. package/types/server/redis.d.ts +163 -0
  146. package/types/server/render.d.ts +101 -0
  147. package/types/server/router.d.ts +5 -0
  148. package/types/server/status-page.d.ts +24 -0
  149. package/types/server/upstream-limiter.d.ts +123 -0
  150. package/types/server/upstream-tracking.d.ts +42 -0
  151. package/types/shared/cookie-domain.d.ts +29 -0
  152. package/types/templates/layout.render.d.ts +7 -0
  153. package/types/version.d.mts +10 -0
  154. package/types/views/components/loader.d.ts +5 -0
  155. package/types/views/helpers/html.d.ts +39 -0
  156. package/types/views/helpers/tags.d.ts +127 -0
@@ -9,9 +9,12 @@
9
9
  *
10
10
  * Bilet süreç belleğinde; tek süreçte tüm locale host'ları servis eden
11
11
  * kurulumlar için yeterli. Değer kısa session id olmalı.
12
+ *
13
+ * Güvenlik: mint CSRF'den *sonra* mount edilir (create-app); cookie adı
14
+ * allowlist + RFC 6265 doğrulaması; bekleyen bilet ve IP başına mint sınırı.
12
15
  */
13
16
  import { getConfig } from "../../config/index.js";
14
- import { randomToken, setCookie } from "../../http/cookies.js";
17
+ import { isValidCookieName, randomToken, setCookie } from "../../http/cookies.js";
15
18
  import {
16
19
  SHARED_COOKIE_WARN_BYTES,
17
20
  requestIsHttps,
@@ -22,11 +25,26 @@ import { resolveSharedCookieDomain } from "../../shared/cookie-domain.js";
22
25
  /** @type {Map<string, { name: string, value: string, maxAge?: number, exp: number }>} */
23
26
  const tickets = new Map();
24
27
 
28
+ /** IP → { count, windowStart } — basit mint rate limit. */
29
+ /** @type {Map<string, { count: number, windowStart: number }>} */
30
+ const mintWindows = new Map();
31
+
25
32
  const DEFAULT_TTL_SECONDS = 60;
33
+ const DEFAULT_MAX_PENDING = 256;
34
+ const DEFAULT_MAX_MINTS_PER_IP = 30;
35
+ const MINT_WINDOW_MS = 60_000;
26
36
  const HANDOFF_PATH = "/_jskelet/auth/handoff";
27
37
 
28
38
  /**
29
- * @returns {{ enabled: boolean, ttlSeconds: number, path: string, maxValueBytes: number }}
39
+ * @returns {{
40
+ * enabled: boolean,
41
+ * ttlSeconds: number,
42
+ * path: string,
43
+ * maxValueBytes: number,
44
+ * allowedCookieNames: string[],
45
+ * maxPendingTickets: number,
46
+ * maxMintsPerIpPerMinute: number,
47
+ * }}
30
48
  */
31
49
  function settings() {
32
50
  const auth = getConfig().auth ?? {};
@@ -37,21 +55,26 @@ function settings() {
37
55
  ttlSeconds: DEFAULT_TTL_SECONDS,
38
56
  path: HANDOFF_PATH,
39
57
  maxValueBytes: SHARED_COOKIE_WARN_BYTES,
58
+ allowedCookieNames: [],
59
+ maxPendingTickets: DEFAULT_MAX_PENDING,
60
+ maxMintsPerIpPerMinute: DEFAULT_MAX_MINTS_PER_IP,
40
61
  };
41
62
  }
42
63
 
43
- if (raw === true) {
44
- return {
45
- enabled: true,
46
- ttlSeconds: DEFAULT_TTL_SECONDS,
47
- path: HANDOFF_PATH,
48
- maxValueBytes: SHARED_COOKIE_WARN_BYTES,
49
- };
50
- }
64
+ /** @type {Record<string, unknown>} */
65
+ const source = raw === true ? {} : /** @type {Record<string, unknown>} */ (raw);
51
66
 
52
- const source = /** @type {Record<string, unknown>} */ (raw);
53
67
  const ttl = Number(source.ttlSeconds);
54
68
  const maxBytes = Number(source.maxValueBytes);
69
+ const maxPending = Number(source.maxPendingTickets);
70
+ const maxMints = Number(source.maxMintsPerIpPerMinute);
71
+
72
+ const namesRaw = source.allowedCookieNames;
73
+ /** @type {string[]} */
74
+ const allowedCookieNames = Array.isArray(namesRaw)
75
+ ? namesRaw.filter((n) => typeof n === "string" && isValidCookieName(n.trim())).map((n) => n.trim())
76
+ : [];
77
+
55
78
  return {
56
79
  enabled: source.enabled !== false,
57
80
  ttlSeconds:
@@ -64,6 +87,15 @@ function settings() {
64
87
  Number.isFinite(maxBytes) && maxBytes > 0
65
88
  ? Math.floor(maxBytes)
66
89
  : SHARED_COOKIE_WARN_BYTES,
90
+ allowedCookieNames,
91
+ maxPendingTickets:
92
+ Number.isFinite(maxPending) && maxPending > 0
93
+ ? Math.floor(maxPending)
94
+ : DEFAULT_MAX_PENDING,
95
+ maxMintsPerIpPerMinute:
96
+ Number.isFinite(maxMints) && maxMints > 0
97
+ ? Math.floor(maxMints)
98
+ : DEFAULT_MAX_MINTS_PER_IP,
67
99
  };
68
100
  }
69
101
 
@@ -74,6 +106,24 @@ function sweep() {
74
106
  }
75
107
  }
76
108
 
109
+ /**
110
+ * @param {string} ip
111
+ * @param {number} limit
112
+ * @returns {boolean} true ise mint'e izin var
113
+ */
114
+ function allowMint(ip, limit) {
115
+ const now = Date.now();
116
+ const key = ip || "unknown";
117
+ const entry = mintWindows.get(key);
118
+ if (!entry || now - entry.windowStart >= MINT_WINDOW_MS) {
119
+ mintWindows.set(key, { count: 1, windowStart: now });
120
+ return true;
121
+ }
122
+ if (entry.count >= limit) return false;
123
+ entry.count += 1;
124
+ return true;
125
+ }
126
+
77
127
  /**
78
128
  * @param {string} next
79
129
  * @param {import('express').Request} req
@@ -146,6 +196,7 @@ function redeem(ticketId) {
146
196
 
147
197
  /**
148
198
  * Handoff uçlarını mount eder. `auth.crossSubdomainHandoff` kapalıysa no-op.
199
+ * create-app CSRF'den *sonra* çağırmalıdır.
149
200
  *
150
201
  * @param {import('express').Express} app
151
202
  * @returns {void}
@@ -154,9 +205,27 @@ export function mountAuthHandoff(app) {
154
205
  const cfg = settings();
155
206
  if (!cfg.enabled) return;
156
207
 
208
+ if (cfg.allowedCookieNames.length === 0) {
209
+ console.warn(
210
+ "[auth.handoff] enabled but allowedCookieNames is empty — " +
211
+ "POST mint will return 400 until you list cookie names in " +
212
+ "auth.crossSubdomainHandoff.allowedCookieNames",
213
+ );
214
+ }
215
+
157
216
  app.post(cfg.path, (req, res) => {
158
217
  sweep();
159
218
 
219
+ if (!allowMint(String(req.ip || ""), cfg.maxMintsPerIpPerMinute)) {
220
+ res.status(429).json({ ok: false, error: "too many handoff requests" });
221
+ return;
222
+ }
223
+
224
+ if (tickets.size >= cfg.maxPendingTickets) {
225
+ res.status(503).json({ ok: false, error: "handoff ticket store is full" });
226
+ return;
227
+ }
228
+
160
229
  const name = typeof req.body?.name === "string" ? req.body.name.trim() : "";
161
230
  const value = typeof req.body?.value === "string" ? req.body.value : "";
162
231
  const next = typeof req.body?.next === "string" ? req.body.next.trim() : "";
@@ -170,6 +239,19 @@ export function mountAuthHandoff(app) {
170
239
  return;
171
240
  }
172
241
 
242
+ if (!isValidCookieName(name)) {
243
+ res.status(400).json({ ok: false, error: "invalid cookie name" });
244
+ return;
245
+ }
246
+
247
+ if (cfg.allowedCookieNames.length === 0 || !cfg.allowedCookieNames.includes(name)) {
248
+ res.status(400).json({
249
+ ok: false,
250
+ error: "cookie name is not in allowedCookieNames",
251
+ });
252
+ return;
253
+ }
254
+
173
255
  if (Buffer.byteLength(value, "utf8") > cfg.maxValueBytes) {
174
256
  res.status(400).json({
175
257
  ok: false,
@@ -218,6 +300,7 @@ export function mountAuthHandoff(app) {
218
300
  /** @returns {void} */
219
301
  export function _resetHandoffTickets() {
220
302
  tickets.clear();
303
+ mintWindows.clear();
221
304
  }
222
305
 
223
306
  /** @returns {number} */
@@ -12,6 +12,9 @@
12
12
  * açık kurallar istenen yolu önce görür.
13
13
  * 3b. access log (açıksa) — tamamlanan yanıtların süresi; admin/prewarm
14
14
  * içeride elenir.
15
+ * 3c. robots.txt — statikten önce, compression'ın içinde: kullanıcının
16
+ * gövdesine framework Disallow bloğu eklenir. Sıkıştırılmış kopyanın
17
+ * (Content-Encoding dolu) üzerine yazılmaz.
15
18
  * 4. staticPrecompressed → express.static — build'de üretilmiş `.br`/`.gz`
16
19
  * kopyalar varsa onlar servis edilir (kalite 11), yoksa istek altındaki
17
20
  * static'e düşer ve middleware anında sıkıştırır (kalite 5).
@@ -21,18 +24,18 @@
21
24
  * body parser'dan önce, admin ile aynı katmanda.
22
25
  * 5. body parser'lar — statikten sonra: görsel isteklerinde gövde ayrıştırma
23
26
  * maliyeti ödenmesin.
24
- * 5b. auth handoff (açıksa) — POST bilet + `?handoff=` redeem; csrf'den
25
- * önce değil sonra: JSON gövde parser'ı hazır olsun. Redeem GET route
26
- * render'ından önce olmalı.
27
- * 6. csrf — body parser'lardan sonra olmalı: token form alanından okunuyor.
27
+ * 5b. csrf — body parser'lardan sonra: token form alanından okunuyor.
28
28
  * Rewrite'lardan önce, çünkü kontrol istemcinin gördüğü yola bakar.
29
- * 7. rewrites(afterFiles) — statik denendikten sonra, sayfalardan önce.
30
- * 8. route'lar → 404 → hata yönetimi.
29
+ * 5c. auth handoff (açıksa) — CSRF'den *sonra*: mint POST origin koruması
30
+ * görsün. Redeem GET güvenli metot; route render'ından önce kalır.
31
+ * 6. rewrites(afterFiles) — statik denendikten sonra, sayfalardan önce.
32
+ * 7. route'lar → 404 → hata yönetimi.
31
33
  */
32
34
  import path from "node:path";
33
35
  import process from "node:process";
34
36
  import express from "express";
35
37
  import { compression } from "./middleware/compression.js";
38
+ import { robotsTxtMiddleware } from "./middleware/robots-txt.js";
36
39
  import { headersMiddleware } from "./middleware/headers.js";
37
40
  import { csrf } from "./middleware/csrf.js";
38
41
  import { staticPrecompressed } from "./middleware/static-precompressed.js";
@@ -51,6 +54,7 @@ import { configureUpstreamLimiter } from "./upstream-limiter.js";
51
54
  import { connectRedis, disconnectRedis } from "./redis.js";
52
55
  import { configureLogs, flushLogs, closeLogs } from "./logs/pipeline.js";
53
56
  import { isNotFoundError, isRedirectError } from "../http/control-flow.js";
57
+ import { ensurePortFree } from "./port-guard.js";
54
58
 
55
59
  /**
56
60
  * @param {{ root?: string, configFile?: string }} [options]
@@ -109,6 +113,11 @@ export async function createApp(options = {}) {
109
113
  app.use(accessLogMiddleware());
110
114
  }
111
115
 
116
+ // Kullanıcının robots.txt'i statik dosya da olabilir, route da. İkisi de
117
+ // bu sarmalayıcıdan geçer. compression'dan sonra durur: ek, sıkıştırılmış
118
+ // baytların değil düz metnin sonuna yazılır.
119
+ app.use(robotsTxtMiddleware());
120
+
112
121
  app.use(staticPrecompressed(config.dirs.public));
113
122
  app.use(
114
123
  express.static(config.dirs.public, {
@@ -152,6 +161,8 @@ export async function createApp(options = {}) {
152
161
  app.use(express.urlencoded({ extended: false, limit: "64kb" }));
153
162
  app.use(express.json({ limit: "256kb" }));
154
163
 
164
+ app.use(csrf());
165
+
155
166
  const handoff = config.auth?.crossSubdomainHandoff;
156
167
  if (
157
168
  handoff === true ||
@@ -163,8 +174,6 @@ export async function createApp(options = {}) {
163
174
  mountAuthHandoff(app);
164
175
  }
165
176
 
166
- app.use(csrf());
167
-
168
177
  app.use(configRewrites("afterFiles"));
169
178
 
170
179
  await registerRoutes(app);
@@ -222,12 +231,21 @@ export async function createApp(options = {}) {
222
231
  * Uygulamayı kurup dinlemeye başlar. CLI `jskelet start` bunu çağırır;
223
232
  * gömülü kullanımda `createApp()` tercih edilir.
224
233
  *
225
- * @param {{ root?: string, configFile?: string, port?: number, host?: string }} [options]
234
+ * Port doluysa başlamaz. `murder: true` veya argv'de `--murder` varsa
235
+ * dinleyen süreç öldürülüp bağlama denenir.
236
+ *
237
+ * @param {{ root?: string, configFile?: string, port?: number, host?: string, murder?: boolean }} [options]
226
238
  * @returns {Promise<import('http').Server>}
227
239
  */
228
240
  export async function startServer(options = {}) {
229
- const app = await createApp(options);
230
241
  const port = Number(options.port ?? process.env.PORT ?? 3000);
242
+ const murder =
243
+ options.murder === true || process.argv.includes("--murder");
244
+
245
+ // createApp pahalı; dolu portta uygulama kurmadan önce net hata ver.
246
+ await ensurePortFree(port, { murder });
247
+
248
+ const app = await createApp(options);
231
249
 
232
250
  // Varsayılan `::`, `0.0.0.0` değil: ikisi de "tüm arayüzler" demek, ama
233
251
  // yalnızca IPv6 soketi çift yığın çalışır ve `localhost`un `::1`e çözüldüğü
@@ -276,6 +294,15 @@ export async function startServer(options = {}) {
276
294
  listen("0.0.0.0");
277
295
  return;
278
296
  }
297
+ if (error.code === "EADDRINUSE") {
298
+ const hint = murder
299
+ ? `port ${port} is still in use after --murder`
300
+ : `port ${port} is already in use. Pass --murder to kill it and start, or set PORT to another value.`;
301
+ const wrapped = new Error(hint);
302
+ /** @type {NodeJS.ErrnoException} */ (wrapped).code = "EADDRINUSE";
303
+ reject(wrapped);
304
+ return;
305
+ }
279
306
  reject(error);
280
307
  });
281
308
 
@@ -0,0 +1,59 @@
1
+ /**
2
+ * Legacy EJS şablon motoru — opsiyonel peer.
3
+ *
4
+ * `.jsk`-only uygulamalar `ejs` kurmadan çalışır. Bir `.ejs` view veya layout
5
+ * istendiğinde bu modül uygulamadan (yoksa framework'ten) `ejs` yükler;
6
+ * paket yoksa göç yolunu gösteren net bir hata verir.
7
+ */
8
+ import { createRequire } from "node:module";
9
+ import { pathToFileURL } from "node:url";
10
+ import path from "node:path";
11
+ import { getConfig, FRAMEWORK_ROOT } from "../config/index.js";
12
+ import { tryImportFromApp } from "../build/resolve-peer.mjs";
13
+
14
+ /** @type {import('ejs') | null | undefined} */
15
+ let cached;
16
+
17
+ const MISSING =
18
+ "EJS templates require the optional peer dependency `ejs`. " +
19
+ "Install it (`npm i ejs`) or migrate the template to `.jsk`.";
20
+
21
+ /**
22
+ * @returns {Promise<import('ejs')>}
23
+ */
24
+ export async function loadEjs() {
25
+ if (cached) return cached;
26
+ if (cached === null) throw new Error(MISSING);
27
+
28
+ const config = getConfig();
29
+ let mod = await tryImportFromApp(config.root, "ejs");
30
+
31
+ // Framework kendi test/dev ortamında peer olarak tutuyor olabilir.
32
+ if (!mod) {
33
+ try {
34
+ const require = createRequire(path.join(FRAMEWORK_ROOT, "package.json"));
35
+ mod = await import(pathToFileURL(require.resolve("ejs")).href);
36
+ } catch {
37
+ mod = null;
38
+ }
39
+ }
40
+
41
+ if (!mod) {
42
+ cached = null;
43
+ throw new Error(MISSING);
44
+ }
45
+
46
+ cached = /** @type {import('ejs')} */ (mod.default ?? mod);
47
+ return cached;
48
+ }
49
+
50
+ /**
51
+ * @param {string} file
52
+ * @param {Record<string, unknown>} data
53
+ * @param {object} options
54
+ * @returns {Promise<string>}
55
+ */
56
+ export async function renderEjsFile(file, data, options) {
57
+ const ejs = await loadEjs();
58
+ return ejs.renderFile(file, data, options);
59
+ }
@@ -31,10 +31,13 @@
31
31
  */
32
32
 
33
33
  import { getConfig } from "../config/index.js";
34
- import { DEFAULT_HTML_CACHE_MAX_ENTRIES } from "../config/defaults.js";
34
+ import {
35
+ DEFAULT_HTML_CACHE_MAX_ENTRIES,
36
+ HTML_CACHE_BYTE_BUDGET,
37
+ } from "../config/defaults.js";
35
38
  import { collectDependencies } from "./cache-deps.js";
36
39
  import { compilePattern, matchPattern } from "../config/pattern.js";
37
- import { pathOfCacheKey } from "./cache-vary.js";
40
+ import { pathOfCacheKey, publicHost } from "./cache-vary.js";
38
41
  import {
39
42
  cacheKey,
40
43
  onCacheEvent,
@@ -118,6 +121,19 @@ const EARLY_SWEEP_INTERVAL_MS = 1000;
118
121
  /** @type {Map<string, HtmlEntry>} */
119
122
  const store = new Map();
120
123
 
124
+ /**
125
+ * `store` içindeki HTML string + sıkıştırılmış gövdelerin toplamı.
126
+ * Her `drop` / `install` bunu günceller; sıkıştırma sonradan eklendiğinde
127
+ * `noteHtmlCacheGrowth` artışı yazar. Tam tarama yapmamak için tutulur.
128
+ */
129
+ let storedBytes = 0;
130
+
131
+ /**
132
+ * Testler tahliyeyi küçük bir bütçeyle doğrular. `null` → üretim tavanı.
133
+ * @type {number | null}
134
+ */
135
+ let byteBudgetOverride = null;
136
+
121
137
  /** @type {Map<string, Promise<{ html: string, status: number }>>} */
122
138
  const inflight = new Map();
123
139
 
@@ -237,8 +253,45 @@ function unlink(key, entry) {
237
253
  }
238
254
 
239
255
  /**
240
- * Store'dan silmenin **tek** yolu. Ters indeks bakımı buraya bağlı olduğu için
241
- * hiçbir yerde doğrudan `store.delete()` çağrılmaz.
256
+ * Ham HTML + sıkıştırılmış gövdeler. Bayt bütçesi girdi sayısından bağımsız
257
+ * bu ağırlığa bakar.
258
+ *
259
+ * @param {HtmlEntry} entry
260
+ * @returns {number}
261
+ */
262
+ function entryBytes(entry) {
263
+ let bytes = Buffer.byteLength(entry.html);
264
+ for (const buffer of entry.encoded.values()) bytes += buffer.length;
265
+ return bytes;
266
+ }
267
+
268
+ /**
269
+ * @returns {number}
270
+ */
271
+ function activeByteBudget() {
272
+ return byteBudgetOverride ?? HTML_CACHE_BYTE_BUDGET;
273
+ }
274
+
275
+ /**
276
+ * Sayı tavanı veya bayt bütçesi aşılınca en eski girdiden düşer. Tek başına
277
+ * bütçeyi aşan girdi de gider: yanıt o istekte zaten üretilmiştir, L1'de
278
+ * durması süreci şişirir.
279
+ */
280
+ function evictOverflow() {
281
+ const limit = maxEntries();
282
+ const budget = activeByteBudget();
283
+
284
+ while (store.size > 0 && (store.size > limit || storedBytes > budget)) {
285
+ const oldest = store.keys().next().value;
286
+ if (oldest === undefined) break;
287
+ if (!drop(oldest)) break;
288
+ }
289
+ }
290
+
291
+ /**
292
+ * Store'dan silmenin **tek** yolu. Ters indeks ve bayt sayacı buraya bağlı;
293
+ * hiçbir yerde doğrudan `store.delete()` çağrılmaz. `read()` içindeki
294
+ * sil-yaz yalnızca LRU sırası içindir, sayacı değiştirmez.
242
295
  *
243
296
  * @param {string} key
244
297
  * @returns {boolean} Girdi var mıydı.
@@ -248,6 +301,7 @@ function drop(key) {
248
301
  if (!entry) return false;
249
302
 
250
303
  unlink(key, entry);
304
+ storedBytes = Math.max(0, storedBytes - entryBytes(entry));
251
305
  store.delete(key);
252
306
  return true;
253
307
  }
@@ -406,8 +460,9 @@ function write(key, value, ttlSeconds, deps = null, produceMs = DEFAULT_PRODUCE_
406
460
  produceMs: normalizeProduceMs(produceMs),
407
461
  };
408
462
 
409
- install(key, entry);
410
- share(key, entry);
463
+ // Bütçeye sığmayan sayfa Redis'e de yazılmaz: bir sonraki istek onu
464
+ // geri alıp yine reddeder, stringify ise o anki RSS'i şişirir.
465
+ if (install(key, entry)) share(key, entry);
411
466
  }
412
467
 
413
468
  /**
@@ -416,13 +471,20 @@ function write(key, value, ttlSeconds, deps = null, produceMs = DEFAULT_PRODUCE_
416
471
  *
417
472
  * @param {string} key
418
473
  * @param {HtmlEntry} entry
474
+ * @returns {boolean} Girdi L1'e girdi mi.
419
475
  */
420
476
  function install(key, entry) {
421
477
  // Aynı anahtarın eski girdisi ters indekste kalmasın: bağımlılıklar
422
478
  // tazelemeden tazelemeye değişebilir.
423
479
  drop(key);
424
480
 
481
+ const weight = entryBytes(entry);
482
+ // Tek sayfa bütçeden büyükse saklama. Eski kopya da düştü; bu yanıt
483
+ // üreticinin döndürdüğü HTML ile gider, L1'e girmez.
484
+ if (weight > activeByteBudget()) return false;
485
+
425
486
  store.set(key, entry);
487
+ storedBytes += weight;
426
488
 
427
489
  for (const dep of entry.deps) {
428
490
  let set = dependents.get(dep);
@@ -430,12 +492,35 @@ function install(key, entry) {
430
492
  set.add(key);
431
493
  }
432
494
 
433
- const limit = maxEntries();
434
- while (store.size > limit) {
435
- const oldest = store.keys().next().value;
436
- if (oldest === undefined) break;
437
- drop(oldest);
438
- }
495
+ evictOverflow();
496
+ return store.has(key);
497
+ }
498
+
499
+ /**
500
+ * Sıkıştırılmış gövde `install()`'dan sonra, ilk brotli/gzip yanıtında
501
+ * girdinin `encoded` haritasına eklenir. Sayacı delta ile büyütmek, o sıra
502
+ * LRU'dan düşmüş bir haritaya yazınca bir daha inmeyen bir artık bırakır;
503
+ * store'dan yeniden okumak o artığı taşımaz.
504
+ *
505
+ * @returns {void}
506
+ */
507
+ export function noteHtmlCacheGrowth() {
508
+ let bytes = 0;
509
+ for (const entry of store.values()) bytes += entryBytes(entry);
510
+ storedBytes = bytes;
511
+ evictOverflow();
512
+ }
513
+
514
+ /**
515
+ * Bellek freninin bayt tavanını geçici olarak değiştirir. Testler LRU
516
+ * tahliyesini küçük bir değerle doğrular; `null` üretim tavanına döner.
517
+ *
518
+ * @param {number | null} bytes
519
+ * @returns {void}
520
+ */
521
+ export function setHtmlCacheByteBudget(bytes) {
522
+ byteBudgetOverride = bytes == null ? null : bytes;
523
+ evictOverflow();
439
524
  }
440
525
 
441
526
  /**
@@ -589,6 +674,7 @@ export function clearHtmlCache() {
589
674
  */
590
675
  function clearLocal() {
591
676
  store.clear();
677
+ storedBytes = 0;
592
678
  dependents.clear();
593
679
  tokens.clear();
594
680
  invalidated.clear();
@@ -877,27 +963,65 @@ function dropLocalKey(key) {
877
963
  }
878
964
 
879
965
  /**
880
- * Invalidate edilmiş ve henüz kimsenin istemediği yolları döner ve kuyruğu
881
- * boşaltır. Isıtma turu bunları başa alır; iki tur aynı yolu tekrar
882
- * ısıtmasın diye okuma yıkıcıdır.
966
+ * Vary önekinden `h=` parçasını okur. Önek yoksa boş string.
883
967
  *
884
- * Vary öneki (`h=…|`) düşülür — HTTP ısıtması yalnızca yolu ister; host
885
- * ayrımı `prewarm.origins` / istek Host'u ile yapılır.
968
+ * @param {string} key
969
+ * @returns {string}
970
+ */
971
+ function hostOfCacheKey(key) {
972
+ const sep = key.indexOf("|/");
973
+ if (sep === -1) return "";
974
+ const prefix = key.slice(0, sep);
975
+ for (const part of prefix.split("&")) {
976
+ if (part.startsWith("h=")) return part.slice(2);
977
+ }
978
+ return "";
979
+ }
980
+
981
+ /**
982
+ * @param {string} key
983
+ * @returns {string}
984
+ */
985
+ function pathWithQuery(key) {
986
+ const pathname = pathOfCacheKey(key);
987
+ const q = key.indexOf("?");
988
+ if (q === -1) return pathname;
989
+ const query = key.slice(q + 1);
990
+ return query ? `${pathname}?${query}` : pathname;
991
+ }
992
+
993
+ /**
994
+ * Invalidate edilmiş yollar. Okuma yıkıcıdır; iki tur aynı yolu tekrar
995
+ * ısıtmasın. `onlyHost` verilirse başka host'ların anahtarları kuyrukta
996
+ * kalır — süre dolumu onları kendi host'uyla ısıtır, `127.0.0.1` anahtarı
997
+ * açılmaz.
886
998
  *
887
- * @returns {string[]}
999
+ * @param {string} [onlyHost]
1000
+ * @returns {{ path: string, host: string }[]}
888
1001
  */
889
- export function takeInvalidatedPaths() {
1002
+ export function takeInvalidatedTargets(onlyHost) {
890
1003
  if (!invalidated.size) return [];
891
1004
 
892
- const paths = [...invalidated];
893
- invalidated.clear();
894
- return paths.map((key) => {
895
- const pathname = pathOfCacheKey(key);
896
- const q = key.indexOf("?");
897
- if (q === -1) return pathname;
898
- const query = key.slice(q + 1);
899
- return query ? `${pathname}?${query}` : pathname;
900
- });
1005
+ /** @type {{ path: string, host: string }[]} */
1006
+ const taken = [];
1007
+
1008
+ for (const key of [...invalidated]) {
1009
+ const host = hostOfCacheKey(key);
1010
+ if (onlyHost && host && host !== onlyHost) continue;
1011
+ invalidated.delete(key);
1012
+ taken.push({ path: pathWithQuery(key), host });
1013
+ }
1014
+
1015
+ return taken;
1016
+ }
1017
+
1018
+ /**
1019
+ * Yol listesi. Vary öneki düşülür; host ayrımı `takeInvalidatedTargets`.
1020
+ *
1021
+ * @returns {string[]}
1022
+ */
1023
+ export function takeInvalidatedPaths() {
1024
+ return takeInvalidatedTargets().map((target) => target.path);
901
1025
  }
902
1026
 
903
1027
  /**
@@ -926,14 +1050,36 @@ export function getHtmlCacheEntries() {
926
1050
  * Yol (query'siz) için taze bir HTML girdisi var mı? Ziyaret ısıtması yalnızca
927
1051
  * soğuk / bayat hedefleri kuyruğa alır; HIT'leri yeniden çekmez.
928
1052
  *
1053
+ * Gerçek anahtar `h=host|/yol?` biçimindedir: düz `store.get(pathname)` hem
1054
+ * vary önekini hem sondaki `?` işaretini kaçırır ve sıcak sayfayı yeniden
1055
+ * ısıtır. `vary.host` açıkken yalnızca bu isteğin host'u sayılır; diğer
1056
+ * locale'in kopyası bu yolu sıcak yapmaz.
1057
+ *
929
1058
  * @param {string} pathname
1059
+ * @param {{ headers?: Record<string, unknown>, get?: (name: string) => string | undefined }} [req]
930
1060
  * @returns {boolean}
931
1061
  */
932
- export function isHtmlCacheFresh(pathname) {
1062
+ export function isHtmlCacheFresh(pathname, req) {
933
1063
  if (typeof pathname !== "string" || !pathname.startsWith("/")) return false;
934
- const entry = store.get(pathname);
935
- if (!entry) return false;
936
- return Date.now() < entry.expiresAt;
1064
+
1065
+ let host = "";
1066
+ try {
1067
+ if (getConfig().cacheVary?.host && req) host = publicHost(req);
1068
+ } catch {
1069
+ // Config yokken (testler) önek aranmaz; yol eşleşmesi yeter.
1070
+ }
1071
+
1072
+ const now = Date.now();
1073
+ for (const [key, entry] of store) {
1074
+ if (now >= entry.expiresAt) continue;
1075
+ if (pathOfCacheKey(key) !== pathname) continue;
1076
+ const q = key.indexOf("?");
1077
+ if (q !== -1 && key.slice(q + 1)) continue;
1078
+ if (host && hostOfCacheKey(key) !== host) continue;
1079
+ return true;
1080
+ }
1081
+
1082
+ return false;
937
1083
  }
938
1084
 
939
1085
  /**