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
@@ -8,8 +8,13 @@
8
8
  *
9
9
  * sharp yoksa 302 ile orijinale yönlendirilir — sayfa bozulmaz, tasarruf
10
10
  * olmaz. Deployment notu: remote açıksa sharp runtime bağımlılığıdır.
11
+ *
12
+ * Fetch redirect'leri elle takip edilir: her hop allowlist + blocked-address
13
+ * (ve mümkünse DNS çözümü) ile yeniden doğrulanır — açık redirect SSRF'sini
14
+ * kapatmak için `redirect: "follow"` kullanılmaz.
11
15
  */
12
16
  import crypto from "node:crypto";
17
+ import dns from "node:dns/promises";
13
18
  import fs from "node:fs";
14
19
  import path from "node:path";
15
20
  import process from "node:process";
@@ -22,6 +27,8 @@ let sharpModule;
22
27
  /** @type {string | null} */
23
28
  let cacheDir = null;
24
29
 
30
+ const MAX_REDIRECTS = 5;
31
+
25
32
  /**
26
33
  * @returns {import('../config/index.js').ImagesRemoteConfig | null}
27
34
  */
@@ -49,13 +56,45 @@ export function parseAllowedRemoteUrl(src) {
49
56
  return null;
50
57
  }
51
58
 
52
- if (url.protocol !== "http:" && url.protocol !== "https:") return null;
53
- if (!isHostAllowed(url.hostname, remote.allowHosts)) return null;
54
- if (isBlockedAddress(url.hostname)) return null;
55
-
59
+ if (!isRemoteUrlShapeAllowed(url, remote.allowHosts)) return null;
56
60
  return url;
57
61
  }
58
62
 
63
+ /**
64
+ * Host allowlist + literal private IP; redirect hop'larında da kullanılır.
65
+ *
66
+ * @param {URL} url
67
+ * @param {string[]} allowHosts
68
+ * @returns {boolean}
69
+ */
70
+ export function isRemoteUrlShapeAllowed(url, allowHosts) {
71
+ if (url.protocol !== "http:" && url.protocol !== "https:") return false;
72
+ if (!isHostAllowed(url.hostname, allowHosts)) return false;
73
+ if (isBlockedAddress(url.hostname)) return false;
74
+ return true;
75
+ }
76
+
77
+ /**
78
+ * Hostname'i çözümleyip private IP'ye düşüyorsa reddet (DNS rebinding
79
+ * savunması; TOCTOU kalır ama check-time private resolve yakalanır).
80
+ *
81
+ * @param {string} hostname
82
+ * @returns {Promise<boolean>} true = güvenli
83
+ */
84
+ export async function assertResolvedHostSafe(hostname) {
85
+ if (isBlockedAddress(hostname)) return false;
86
+ try {
87
+ const results = await dns.lookup(hostname, { all: true, verbatim: true });
88
+ if (!results.length) return false;
89
+ for (const { address } of results) {
90
+ if (isBlockedAddress(address)) return false;
91
+ }
92
+ return true;
93
+ } catch {
94
+ return false;
95
+ }
96
+ }
97
+
59
98
  /**
60
99
  * @param {string} hostname
61
100
  * @param {string[]} allowHosts
@@ -257,6 +296,8 @@ async function handleOptimize(req, res, remote) {
257
296
  }
258
297
 
259
298
  /**
299
+ * Redirect'leri elle takip eder; her hop allowlist + DNS private kontrolünden geçer.
300
+ *
260
301
  * @param {string} href
261
302
  * @param {import('../config/index.js').ImagesRemoteConfig} remote
262
303
  * @returns {Promise<{ ok: true, buffer: Buffer } | { ok: false }>}
@@ -266,30 +307,57 @@ async function fetchUpstream(href, remote) {
266
307
  const timer = setTimeout(() => controller.abort(), remote.fetchTimeoutMs);
267
308
 
268
309
  try {
269
- const response = await fetch(href, {
270
- signal: controller.signal,
271
- redirect: "follow",
272
- headers: {
273
- // Bazı CDN'ler bot UA reddeder; tarayıcıya yakın tut.
274
- Accept: "image/avif,image/webp,image/*,*/*;q=0.8",
275
- "User-Agent": "jskelet-image-optimizer/1",
276
- },
277
- });
278
-
279
- if (!response.ok) return { ok: false };
280
-
281
- const type = response.headers.get("content-type") ?? "";
282
- if (type && !type.startsWith("image/") && !type.includes("octet-stream")) {
283
- return { ok: false };
310
+ let current = href;
311
+
312
+ for (let hop = 0; hop <= MAX_REDIRECTS; hop++) {
313
+ let url;
314
+ try {
315
+ url = new URL(current);
316
+ } catch {
317
+ return { ok: false };
318
+ }
319
+
320
+ if (!isRemoteUrlShapeAllowed(url, remote.allowHosts)) return { ok: false };
321
+ if (!(await assertResolvedHostSafe(url.hostname))) return { ok: false };
322
+
323
+ const response = await fetch(url.href, {
324
+ signal: controller.signal,
325
+ redirect: "manual",
326
+ headers: {
327
+ // Bazı CDN'ler bot UA reddeder; tarayıcıya yakın tut.
328
+ Accept: "image/avif,image/webp,image/*,*/*;q=0.8",
329
+ "User-Agent": "jskelet-image-optimizer/1",
330
+ },
331
+ });
332
+
333
+ if (response.status >= 300 && response.status < 400) {
334
+ const location = response.headers.get("location");
335
+ if (!location) return { ok: false };
336
+ try {
337
+ current = new URL(location, url).href;
338
+ } catch {
339
+ return { ok: false };
340
+ }
341
+ continue;
342
+ }
343
+
344
+ if (!response.ok) return { ok: false };
345
+
346
+ const type = response.headers.get("content-type") ?? "";
347
+ if (type && !type.startsWith("image/") && !type.includes("octet-stream")) {
348
+ return { ok: false };
349
+ }
350
+
351
+ const length = Number(response.headers.get("content-length") ?? 0);
352
+ if (length > remote.maxBytes) return { ok: false };
353
+
354
+ const buffer = Buffer.from(await response.arrayBuffer());
355
+ if (buffer.byteLength > remote.maxBytes) return { ok: false };
356
+
357
+ return { ok: true, buffer };
284
358
  }
285
359
 
286
- const length = Number(response.headers.get("content-length") ?? 0);
287
- if (length > remote.maxBytes) return { ok: false };
288
-
289
- const buffer = Buffer.from(await response.arrayBuffer());
290
- if (buffer.byteLength > remote.maxBytes) return { ok: false };
291
-
292
- return { ok: true, buffer };
360
+ return { ok: false };
293
361
  } catch {
294
362
  return { ok: false };
295
363
  } finally {
@@ -1,11 +1,12 @@
1
1
  /**
2
- * Yayına açılmamış bir ortamı gizler: `DEV_TOKEN` ayarlıyken token taşımayan
3
- * her isteğe 404 döner. 403 değil 404 — 403 ortamın var olduğunu doğrular,
4
- * 404 hiç yokmuş gibi davranır.
2
+ * Yayına açılmamış bir ortamı gizler. Gate kapalıyken (varsayılan) `DEV_TOKEN`
3
+ * olsa bile istekler geçer: değişkenin task ortamında unutulması production'u
4
+ * kilitlemesin. Açmak için `devGate: true` ya da `DEV_GATE=1` gerekir; o zaman
5
+ * token taşımayan her isteğe 404 döner. 403 değil 404 — 403 ortamın var
6
+ * olduğunu doğrular, 404 hiç yokmuş gibi davranır.
5
7
  *
6
8
  * Token bir kez `?dev_token=…` ile gelirse çereze yazılır, böylece link
7
- * paylaşımı yeterli olur. `DEV_TOKEN` yoksa middleware tamamen devre dışıdır
8
- * ve üretimde hiçbir maliyeti olmaz.
9
+ * paylaşımı yeterli olur. Token boşsa gate açık olsa da kimse kilitlenmez.
9
10
  */
10
11
  import process from "node:process";
11
12
  import { getConfig } from "../../config/index.js";
@@ -32,13 +33,25 @@ function readCookie(req, name) {
32
33
 
33
34
  /** @returns {import('express').RequestHandler} */
34
35
  export function devGate() {
35
- const { devGateBypass, brand } = getConfig();
36
+ const { devGate: enabled, devGateBypass, brand } = getConfig();
36
37
  const bypass = new Set(devGateBypass);
37
38
  const cookieName = brand.devTokenCookie;
39
+ const devToken = process.env.DEV_TOKEN;
40
+
41
+ // Unutulmuş bir değişken sessizce herkese 404 kesmesin; operatör görsün.
42
+ if (!enabled && devToken) {
43
+ console.warn(
44
+ "[dev-gate] DEV_TOKEN is set but the gate is off, so the site stays public. " +
45
+ "Set devGate: true or DEV_GATE=1 to require the token.",
46
+ );
47
+ } else if (enabled && !devToken) {
48
+ console.warn(
49
+ "[dev-gate] the gate is on but DEV_TOKEN is empty; requests are not blocked.",
50
+ );
51
+ }
38
52
 
39
53
  return (req, res, next) => {
40
- const devToken = process.env.DEV_TOKEN;
41
- if (!devToken) return next();
54
+ if (!enabled || !devToken) return next();
42
55
  if (bypass.has(req.path)) return next();
43
56
 
44
57
  const fromQuery = req.query?.[cookieName];
@@ -0,0 +1,341 @@
1
+ /**
2
+ * Kullanıcının `robots.txt` gövdesinin altına framework uçlarını ekler.
3
+ *
4
+ * Panel, görsel proxy, auth handoff, dev araçları ve fragment parçaları
5
+ * sayfa değildir; bir crawler onları dizinine alırsa indeks parçalı HTML
6
+ * ve yönetim yollarıyla kirlenir. Uygulama dosyayı kendisi yazar (`public/`
7
+ * ya da bir route). Framework yalnızca başarılı metin yanıtının sonuna bir
8
+ * not ve `Disallow` grubu düşer — dosya yoksa uydurulmaz.
9
+ *
10
+ * Google, belirli bir user-agent grubunu `User-agent: *` ile birleştirmez;
11
+ * aynı ajanın ikinci grubunu birleştirir. Bu yüzden alttaki grup, dosyada
12
+ * adı geçen her ajanı tekrarlar. Aksi hâlde yalnızca `Googlebot` grubu olan
13
+ * bir dosyada ek hiç uygulanmazdı.
14
+ *
15
+ * Sıkıştırılmış kopyanın (`Content-Encoding` dolu) üzerine yazılmaz: ek,
16
+ * düz metnin sonuna konur, sıkıştırma dıştaki middleware'de olur.
17
+ */
18
+ import crypto from "node:crypto";
19
+ import { getConfig } from "../../config/index.js";
20
+
21
+ /** İdempotency anahtarı. Marka adı değişse de ikinci kez eklenmez. */
22
+ const NOTE_MARK = "framework endpoints, not for indexing";
23
+
24
+ /** Framework'ün ayırdığı önekler. Özel bir yola taşınsalar da bunlar kalır. */
25
+ const RESERVED_PREFIXES = ["/_jskelet/", "/__jskelet/", "/_fragment/"];
26
+
27
+ /**
28
+ * @param {string} [brandName]
29
+ * @returns {string}
30
+ */
31
+ export function frameworkRobotsNote(brandName = "JSkelet") {
32
+ const name = String(brandName || "JSkelet")
33
+ .replace(/[\r\n]+/g, " ")
34
+ .trim();
35
+ return `# ${name || "JSkelet"} — ${NOTE_MARK}`;
36
+ }
37
+
38
+ /**
39
+ * `robots.txt`'e yazılacak `Disallow` yolları.
40
+ *
41
+ * Üç önek her zaman vardır. Panel, uzak görsel ve handoff yalnızca
42
+ * gerçekten mount edildiklerinde ve öneklerin dışına taşındıklarında eklenir.
43
+ * Dev araçlarının özel yolu yalnızca development'ta yazılır: production'da
44
+ * o yol uygulamanın kendi sayfası olabilir.
45
+ *
46
+ * @param {object} [config]
47
+ * @param {{ dev?: boolean }} [options]
48
+ * @returns {string[]}
49
+ */
50
+ export function frameworkDisallowPaths(config = {}, options = {}) {
51
+ /** @type {string[]} */
52
+ const paths = [...RESERVED_PREFIXES];
53
+ const dev = options.dev ?? process.env.NODE_ENV === "development";
54
+
55
+ /** @type {string[]} */
56
+ const extras = [];
57
+
58
+ const admin = /** @type {{ enabled?: boolean, basePath?: string }} */ (
59
+ config.admin
60
+ );
61
+ if (admin?.enabled && typeof admin.basePath === "string") {
62
+ extras.push(admin.basePath);
63
+ }
64
+
65
+ const images = /** @type {{ remote?: { enabled?: boolean, path?: string } | false } | false} */ (
66
+ config.images
67
+ );
68
+ const remote = images && images !== false ? images.remote : null;
69
+ if (remote && remote !== false && remote.enabled && typeof remote.path === "string") {
70
+ extras.push(remote.path);
71
+ }
72
+
73
+ const auth = /** @type {{ crossSubdomainHandoff?: boolean | { enabled?: boolean, path?: string } }} */ (
74
+ config.auth
75
+ );
76
+ const handoff = auth?.crossSubdomainHandoff;
77
+ const handoffOn =
78
+ handoff === true ||
79
+ (Boolean(handoff) &&
80
+ typeof handoff === "object" &&
81
+ handoff.enabled !== false);
82
+ if (handoffOn && handoff && typeof handoff === "object" && typeof handoff.path === "string") {
83
+ extras.push(handoff.path);
84
+ }
85
+
86
+ const brand = /** @type {{ devBasePath?: string }} */ (config.brand);
87
+ if (dev && typeof brand?.devBasePath === "string") {
88
+ extras.push(brand.devBasePath);
89
+ }
90
+
91
+ for (const raw of extras) {
92
+ const path = cleanDisallowPath(raw);
93
+ if (!path) continue;
94
+ if (paths.some((existing) => coveredBy(existing, path))) continue;
95
+ paths.push(path);
96
+ }
97
+
98
+ return paths;
99
+ }
100
+
101
+ /**
102
+ * @param {string} body
103
+ * @param {{ brandName?: string, paths: string[] }} options
104
+ * @returns {string}
105
+ */
106
+ export function appendFrameworkRobots(body, options) {
107
+ const text = String(body ?? "").replace(/^\uFEFF/, "");
108
+ const paths = options.paths.filter((path) => cleanDisallowPath(path) || isReserved(path));
109
+ if (paths.length === 0 || text.includes(NOTE_MARK)) return text;
110
+
111
+ const agents = collectUserAgents(text);
112
+ const block = [
113
+ frameworkRobotsNote(options.brandName),
114
+ ...agents.map((agent) => `User-agent: ${agent}`),
115
+ ...paths.map((path) => `Disallow: ${path}`),
116
+ "",
117
+ ].join("\n");
118
+
119
+ const base = text.replace(/\s*$/, "");
120
+ return base ? `${base}\n\n${block}` : block;
121
+ }
122
+
123
+ /**
124
+ * @returns {import('express').RequestHandler}
125
+ */
126
+ export function robotsTxtMiddleware() {
127
+ return function robotsTxt(req, res, next) {
128
+ if (req.method !== "GET" && req.method !== "HEAD") {
129
+ next();
130
+ return;
131
+ }
132
+
133
+ const pathname = req.path || "";
134
+ if (pathname !== "/robots.txt") {
135
+ next();
136
+ return;
137
+ }
138
+
139
+ // Handler'ın kendi ETag'i eklenmemiş gövdeye aittir. 304, crawler'ın
140
+ // framework bloğu olmayan kopyayı tutmasına yol açardı.
141
+ const previousEtag = req.headers["if-none-match"];
142
+ delete req.headers["if-none-match"];
143
+ delete req.headers["if-modified-since"];
144
+
145
+ /** @type {Buffer[]} */
146
+ const chunks = [];
147
+ const originalEnd = res.end;
148
+ let finished = false;
149
+
150
+ res.write = function write(chunk, encoding, callback) {
151
+ const enc = typeof encoding === "function" ? undefined : encoding;
152
+ const done = typeof encoding === "function" ? encoding : callback;
153
+ const buf = takeChunk(chunk, enc);
154
+ if (buf && buf.length) chunks.push(buf);
155
+ if (typeof done === "function") done();
156
+ return true;
157
+ };
158
+
159
+ res.end = function end(chunk, encoding, callback) {
160
+ if (finished) return originalEnd.call(this);
161
+ finished = true;
162
+
163
+ const enc = typeof encoding === "function" ? undefined : encoding;
164
+ const done =
165
+ typeof chunk === "function"
166
+ ? chunk
167
+ : typeof encoding === "function"
168
+ ? encoding
169
+ : callback;
170
+ const buf = takeChunk(chunk, enc);
171
+ if (buf && buf.length) chunks.push(buf);
172
+
173
+ const body = chunks.length === 0 ? Buffer.alloc(0) : Buffer.concat(chunks);
174
+
175
+ if (!shouldRewrite(req, res, body)) {
176
+ return typeof done === "function"
177
+ ? originalEnd.call(this, body, done)
178
+ : originalEnd.call(this, body);
179
+ }
180
+
181
+ const context = readRobotsContext();
182
+ const nextBody = Buffer.from(
183
+ appendFrameworkRobots(body.toString("utf8"), context),
184
+ "utf8",
185
+ );
186
+ const tag = `"${crypto.createHash("sha1").update(nextBody).digest("base64url")}"`;
187
+
188
+ if (previousEtag && etagMatches(previousEtag, tag)) {
189
+ res.statusCode = 304;
190
+ res.setHeader("ETag", tag);
191
+ res.setHeader("Content-Length", "0");
192
+ res.removeHeader("Last-Modified");
193
+ return originalEnd.call(this);
194
+ }
195
+
196
+ const type = String(res.getHeader("content-type") || "");
197
+ if (!/^text\/plain\b/i.test(type)) {
198
+ res.setHeader("Content-Type", "text/plain; charset=utf-8");
199
+ }
200
+ res.setHeader("ETag", tag);
201
+ res.setHeader("Content-Length", String(nextBody.length));
202
+ res.removeHeader("Last-Modified");
203
+
204
+ return typeof done === "function"
205
+ ? originalEnd.call(this, nextBody, done)
206
+ : originalEnd.call(this, nextBody);
207
+ };
208
+
209
+ next();
210
+ };
211
+ }
212
+
213
+ /**
214
+ * @returns {{ brandName: string, paths: string[] }}
215
+ */
216
+ function readRobotsContext() {
217
+ try {
218
+ const config = getConfig();
219
+ const brand = /** @type {{ name?: string }} */ (config.brand);
220
+ return {
221
+ brandName: typeof brand?.name === "string" && brand.name ? brand.name : "JSkelet",
222
+ paths: frameworkDisallowPaths(config),
223
+ };
224
+ } catch {
225
+ return { brandName: "JSkelet", paths: frameworkDisallowPaths({}) };
226
+ }
227
+ }
228
+
229
+ /**
230
+ * @param {import('express').Request} req
231
+ * @param {import('express').Response} res
232
+ * @param {Buffer} body
233
+ * @returns {boolean}
234
+ */
235
+ function shouldRewrite(req, res, body) {
236
+ if (req.method !== "GET") return false;
237
+ if (res.headersSent) return false;
238
+
239
+ const status = res.statusCode || 200;
240
+ if (status < 200 || status >= 300) return false;
241
+ if (res.getHeader("content-encoding")) return false;
242
+
243
+ const type = String(res.getHeader("content-type") || "");
244
+ if (type && !/^text\/(?:plain|html)\b/i.test(type)) return false;
245
+
246
+ const sample = body.subarray(0, 64).toString("utf8");
247
+ if (/^\s*<(!doctype|html)\b/i.test(sample)) return false;
248
+ return true;
249
+ }
250
+
251
+ /**
252
+ * @param {string | string[] | undefined} header
253
+ * @param {string} tag
254
+ * @returns {boolean}
255
+ */
256
+ function etagMatches(header, tag) {
257
+ const value = Array.isArray(header) ? header.join(",") : String(header);
258
+ return value.split(",").some((part) => {
259
+ const token = part.trim().replace(/^W\//i, "");
260
+ return token === "*" || token === tag;
261
+ });
262
+ }
263
+
264
+ /**
265
+ * @param {unknown} chunk
266
+ * @param {unknown} encoding
267
+ * @returns {Buffer | null}
268
+ */
269
+ function takeChunk(chunk, encoding) {
270
+ if (chunk == null || typeof chunk === "function") return null;
271
+ if (Buffer.isBuffer(chunk)) return chunk;
272
+ if (chunk instanceof Uint8Array) return Buffer.from(chunk);
273
+ if (typeof chunk === "string") {
274
+ const enc = typeof encoding === "string" ? encoding : "utf8";
275
+ return Buffer.from(chunk, /** @type {BufferEncoding} */ (enc));
276
+ }
277
+ return null;
278
+ }
279
+
280
+ /**
281
+ * @param {string} text
282
+ * @returns {string[]}
283
+ */
284
+ function collectUserAgents(text) {
285
+ /** @type {string[]} */
286
+ const agents = [];
287
+ const seen = new Set();
288
+
289
+ for (const line of text.split(/\r?\n/)) {
290
+ const match = /^[ \t]*user-agent[ \t]*:[ \t]*(.*?)[ \t]*$/i.exec(line);
291
+ if (!match) continue;
292
+ const agent = match[1].trim();
293
+ if (!agent || /[\r\n]/.test(agent)) continue;
294
+ const key = agent.toLowerCase();
295
+ if (seen.has(key)) continue;
296
+ seen.add(key);
297
+ agents.push(agent);
298
+ }
299
+
300
+ const star = agents.findIndex((agent) => agent === "*");
301
+ if (star === -1) agents.unshift("*");
302
+ else if (star > 0) {
303
+ agents.splice(star, 1);
304
+ agents.unshift("*");
305
+ }
306
+
307
+ return agents;
308
+ }
309
+
310
+ /**
311
+ * @param {string} raw
312
+ * @returns {string | null}
313
+ */
314
+ function cleanDisallowPath(raw) {
315
+ if (typeof raw !== "string") return null;
316
+ const pathOnly = raw.split("?")[0].trim();
317
+ if (!pathOnly.startsWith("/") || /[\s#]/.test(pathOnly)) return null;
318
+ const trimmed = pathOnly.replace(/\/+$/, "");
319
+ return trimmed || null;
320
+ }
321
+
322
+ /**
323
+ * @param {string} path
324
+ * @returns {boolean}
325
+ */
326
+ function isReserved(path) {
327
+ return RESERVED_PREFIXES.includes(path);
328
+ }
329
+
330
+ /**
331
+ * @param {string} rule
332
+ * @param {string} candidate
333
+ * @returns {boolean}
334
+ */
335
+ function coveredBy(rule, candidate) {
336
+ const ruleExact = rule.replace(/\/+$/, "");
337
+ const candExact = candidate.replace(/\/+$/, "");
338
+ if (candExact === ruleExact) return true;
339
+ const prefix = rule.endsWith("/") ? rule : `${ruleExact}/`;
340
+ return candidate.startsWith(prefix) || `${candExact}/`.startsWith(prefix);
341
+ }