jskelet 0.1.1 → 0.1.2
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 +63 -0
- 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 +39 -7
- package/docs/07-yapilandirma.md +51 -1
- 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 +454 -0
- package/docs/en/07-configuration.md +736 -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 +34 -0
- package/src/config/index.js +68 -13
- 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 +19 -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/dev/devtools.js +6 -2
- package/src/server/dev/version-check.mjs +139 -0
- package/src/server/head-hints.js +1 -1
- package/src/server/html-cache.js +10 -4
- package/src/server/middleware/csrf.js +134 -0
- package/src/server/prewarm.js +6 -6
- package/src/server/render.js +199 -16
- 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/init.mjs
CHANGED
|
@@ -12,43 +12,43 @@ import * as log from "./log.mjs";
|
|
|
12
12
|
/** @type {Record<string, string>} */
|
|
13
13
|
const FILES = {
|
|
14
14
|
"jskelet.config.mjs": `/**
|
|
15
|
-
* JSkelet
|
|
16
|
-
*
|
|
15
|
+
* JSkelet configuration. Every field is optional; the app still runs on
|
|
16
|
+
* defaults if you delete this file.
|
|
17
17
|
*
|
|
18
|
-
*
|
|
18
|
+
* Details: node_modules/jskelet/docs/en/07-configuration.md
|
|
19
19
|
*/
|
|
20
20
|
export default {
|
|
21
|
-
brand: { lang: "
|
|
21
|
+
brand: { lang: "en" },
|
|
22
22
|
|
|
23
|
-
/**
|
|
23
|
+
/** Third-party origins; emitted as preconnect in \`<head>\`. */
|
|
24
24
|
preconnect: [],
|
|
25
25
|
|
|
26
26
|
async cache() {
|
|
27
27
|
return {
|
|
28
|
-
/**
|
|
28
|
+
/** How long a page's HTML stays in the cache (seconds). */
|
|
29
29
|
html: { "/": 60 },
|
|
30
30
|
};
|
|
31
31
|
},
|
|
32
32
|
|
|
33
33
|
hooks: {
|
|
34
|
-
/**
|
|
34
|
+
/** Metadata defaults for every page. */
|
|
35
35
|
metadata() {
|
|
36
36
|
return {
|
|
37
37
|
titleTemplate: "%s | JSkelet",
|
|
38
|
-
description: "
|
|
38
|
+
description: "A site built with JSkelet.",
|
|
39
39
|
};
|
|
40
40
|
},
|
|
41
41
|
|
|
42
|
-
/**
|
|
42
|
+
/** Locals added to the layout on every render. */
|
|
43
43
|
layoutContext() {
|
|
44
44
|
return { bodyClass: "min-h-full" };
|
|
45
45
|
},
|
|
46
46
|
|
|
47
|
-
/** 404
|
|
47
|
+
/** 404 page. */
|
|
48
48
|
notFound() {
|
|
49
49
|
return {
|
|
50
50
|
view: "pages/not-found",
|
|
51
|
-
metadata: { title: "
|
|
51
|
+
metadata: { title: "Page not found", robots: { index: false } },
|
|
52
52
|
};
|
|
53
53
|
},
|
|
54
54
|
},
|
|
@@ -56,11 +56,12 @@ export default {
|
|
|
56
56
|
`,
|
|
57
57
|
|
|
58
58
|
"routes/10-pages.mjs": `/**
|
|
59
|
-
* Route
|
|
60
|
-
* HTML cache
|
|
59
|
+
* Route module. The default export receives \`(app, api)\`; \`api.route()\` wraps
|
|
60
|
+
* the controller with the HTML cache, the notFound/redirect flow and
|
|
61
|
+
* compression.
|
|
61
62
|
*
|
|
62
|
-
*
|
|
63
|
-
* ("/:slug"
|
|
63
|
+
* The numeric prefix in the file name sets load order: catch-all routes
|
|
64
|
+
* (like "/:slug") belong to a higher number.
|
|
64
65
|
*/
|
|
65
66
|
export default function register(app, { route }) {
|
|
66
67
|
app.get(
|
|
@@ -68,8 +69,8 @@ export default function register(app, { route }) {
|
|
|
68
69
|
route(
|
|
69
70
|
async () => ({
|
|
70
71
|
view: "pages/home",
|
|
71
|
-
metadata: { title: "
|
|
72
|
-
data: { message: "JSkelet
|
|
72
|
+
metadata: { title: "Home" },
|
|
73
|
+
data: { message: "JSkelet is running." },
|
|
73
74
|
}),
|
|
74
75
|
{ revalidate: 60 },
|
|
75
76
|
),
|
|
@@ -86,16 +87,16 @@ export default function register(app, { route }) {
|
|
|
86
87
|
|
|
87
88
|
"views/pages/not-found.ejs": `<section class="wrapper">
|
|
88
89
|
<h1>404</h1>
|
|
89
|
-
<p>
|
|
90
|
-
<p><a href="/">
|
|
90
|
+
<p>The page you are looking for was not found.</p>
|
|
91
|
+
<p><a href="/">Back to home</a></p>
|
|
91
92
|
</section>
|
|
92
93
|
`,
|
|
93
94
|
|
|
94
95
|
"views/components/button.js": `import { attrs, esc } from "jskelet/html";
|
|
95
96
|
|
|
96
97
|
/**
|
|
97
|
-
* \`views/components/**\`
|
|
98
|
-
*
|
|
98
|
+
* Every named export under \`views/components/**\` is usable directly in
|
|
99
|
+
* templates: \`<%- button({ text: "Save" }) %>\`. No import needed.
|
|
99
100
|
*
|
|
100
101
|
* @param {{ text: string, href?: string, class?: string }} props
|
|
101
102
|
* @returns {string}
|
|
@@ -109,8 +110,8 @@ export function button({ text, href, class: className }) {
|
|
|
109
110
|
"client/entries/main.js": `import { registerAll, start } from "jskelet/client";
|
|
110
111
|
|
|
111
112
|
/**
|
|
112
|
-
* Island
|
|
113
|
-
*
|
|
113
|
+
* Island registry. Values are dynamic imports: a module is downloaded only if
|
|
114
|
+
* that island is actually on the page and becomes visible.
|
|
114
115
|
*/
|
|
115
116
|
registerAll({
|
|
116
117
|
counter: () => import("../islands/counter.js"),
|
|
@@ -120,8 +121,8 @@ start();
|
|
|
120
121
|
`,
|
|
121
122
|
|
|
122
123
|
"client/islands/counter.js": `/**
|
|
123
|
-
* Island
|
|
124
|
-
*
|
|
124
|
+
* Island contract: a named export called \`mount(element, props)\`.
|
|
125
|
+
* The returned function, if any, is reserved for cleanup.
|
|
125
126
|
*
|
|
126
127
|
* @param {HTMLElement} element
|
|
127
128
|
* @param {{ start?: number }} props
|
|
@@ -133,7 +134,7 @@ export function mount(element, props) {
|
|
|
133
134
|
button.type = "button";
|
|
134
135
|
|
|
135
136
|
const paint = () => {
|
|
136
|
-
button.textContent = \`
|
|
137
|
+
button.textContent = \`Clicks: \${value}\`;
|
|
137
138
|
};
|
|
138
139
|
|
|
139
140
|
button.addEventListener("click", () => {
|
|
@@ -149,9 +150,9 @@ export function mount(element, props) {
|
|
|
149
150
|
"styles/globals.css": `@import "tailwindcss" source(none);
|
|
150
151
|
|
|
151
152
|
/**
|
|
152
|
-
* Tailwind'
|
|
153
|
-
*
|
|
154
|
-
* (data-[active=false]:…
|
|
153
|
+
* Tailwind's class scanning depends on these directives. Automatic detection
|
|
154
|
+
* only scans the directory holding this file; variants used in templates
|
|
155
|
+
* (like data-[active=false]:…) would otherwise be dropped silently.
|
|
155
156
|
*/
|
|
156
157
|
@source "../views";
|
|
157
158
|
@source "../client";
|
|
@@ -213,8 +214,8 @@ export async function init(root) {
|
|
|
213
214
|
}
|
|
214
215
|
|
|
215
216
|
for (const file of created) log.line(`+ ${file}`);
|
|
216
|
-
if (skipped.length) log.warn(`${skipped.length}
|
|
217
|
+
if (skipped.length) log.warn(`${skipped.length} files already existed, skipped`);
|
|
217
218
|
|
|
218
219
|
log.line("");
|
|
219
|
-
log.line("
|
|
220
|
+
log.line("next step: npx jskelet dev");
|
|
220
221
|
}
|
package/src/log.mjs
CHANGED
|
@@ -75,9 +75,15 @@ function clearLine() {
|
|
|
75
75
|
if (isTTY) write("\r\u001b[2K");
|
|
76
76
|
}
|
|
77
77
|
|
|
78
|
-
/**
|
|
78
|
+
/**
|
|
79
|
+
* Saat, yerelden bağımsız olarak 24 saatlik biçimde. Dil etiketi vermek
|
|
80
|
+
* sunucunun bulunduğu makinenin diline göre `ÖÖ/ÖS` ya da `AM/PM` basılmasına
|
|
81
|
+
* yol açıyordu; log satırının genişliği sabit kalmalı.
|
|
82
|
+
*
|
|
83
|
+
* @returns {string} `01:49:02`
|
|
84
|
+
*/
|
|
79
85
|
export function clock() {
|
|
80
|
-
return new Date().toLocaleTimeString("
|
|
86
|
+
return new Date().toLocaleTimeString("en-GB", { hour12: false });
|
|
81
87
|
}
|
|
82
88
|
|
|
83
89
|
/**
|
package/src/logo.png
CHANGED
|
Binary file
|
|
@@ -56,7 +56,7 @@ function readAliases() {
|
|
|
56
56
|
// Uzun önek önce: `@flags/` `@/`den önce denenmeli, yoksa `@/` yakalar.
|
|
57
57
|
return aliases.sort((a, b) => b.prefix.length - a.prefix.length);
|
|
58
58
|
} catch (error) {
|
|
59
|
-
console.warn(`[alias] ${name}
|
|
59
|
+
console.warn(`[alias] could not read ${name}, aliases disabled`, error);
|
|
60
60
|
return [];
|
|
61
61
|
}
|
|
62
62
|
}
|
package/src/server/assets.js
CHANGED
package/src/server/create-app.js
CHANGED
|
@@ -13,14 +13,17 @@
|
|
|
13
13
|
* static'e düşer ve middleware anında sıkıştırır (kalite 5).
|
|
14
14
|
* 5. body parser'lar — statikten sonra: görsel isteklerinde gövde ayrıştırma
|
|
15
15
|
* maliyeti ödenmesin.
|
|
16
|
-
* 6.
|
|
17
|
-
*
|
|
16
|
+
* 6. csrf — body parser'lardan sonra olmalı: token form alanından okunuyor.
|
|
17
|
+
* Rewrite'lardan önce, çünkü kontrol istemcinin gördüğü yola bakar.
|
|
18
|
+
* 7. rewrites(afterFiles) — statik denendikten sonra, sayfalardan önce.
|
|
19
|
+
* 8. route'lar → 404 → hata yönetimi.
|
|
18
20
|
*/
|
|
19
21
|
import path from "node:path";
|
|
20
22
|
import process from "node:process";
|
|
21
23
|
import express from "express";
|
|
22
24
|
import { compression } from "./middleware/compression.js";
|
|
23
25
|
import { headersMiddleware } from "./middleware/headers.js";
|
|
26
|
+
import { csrf } from "./middleware/csrf.js";
|
|
24
27
|
import { staticPrecompressed } from "./middleware/static-precompressed.js";
|
|
25
28
|
import { devGate } from "./middleware/dev-gate.js";
|
|
26
29
|
import { redirects } from "./middleware/redirects.js";
|
|
@@ -51,8 +54,11 @@ export async function createApp(options = {}) {
|
|
|
51
54
|
});
|
|
52
55
|
|
|
53
56
|
app.set("etag", "strong");
|
|
54
|
-
// Ters proxy arkasında doğru protokol ve istemci IP'si için.
|
|
55
|
-
|
|
57
|
+
// Ters proxy arkasında doğru protokol ve istemci IP'si için. Doğrudan
|
|
58
|
+
// internete açık bir sunucuda kapatılmalı: açıkken istemci kendi
|
|
59
|
+
// `X-Forwarded-For` başlığını uydurabilir ve rate limit ile audit log
|
|
60
|
+
// yanlış IP görür.
|
|
61
|
+
app.set("trust proxy", config.security.trustProxy);
|
|
56
62
|
|
|
57
63
|
app.use(configRewrites("beforeFiles"));
|
|
58
64
|
app.use(compression());
|
|
@@ -87,6 +93,8 @@ export async function createApp(options = {}) {
|
|
|
87
93
|
app.use(express.urlencoded({ extended: false, limit: "64kb" }));
|
|
88
94
|
app.use(express.json({ limit: "256kb" }));
|
|
89
95
|
|
|
96
|
+
app.use(csrf());
|
|
97
|
+
|
|
90
98
|
app.use(configRewrites("afterFiles"));
|
|
91
99
|
|
|
92
100
|
await registerRoutes(app);
|
|
@@ -21,6 +21,7 @@ import {
|
|
|
21
21
|
recordPageReport,
|
|
22
22
|
trackServerFetch,
|
|
23
23
|
} from "./report.js";
|
|
24
|
+
import { startVersionCheck, versionStatus } from "./version-check.mjs";
|
|
24
25
|
|
|
25
26
|
/** Overlay dosyaları framework paketinden servis edilir, uygulamadan değil. */
|
|
26
27
|
const DEVTOOLS_DIR = path.join(FRAMEWORK_ROOT, "src", "client", "devtools");
|
|
@@ -267,7 +268,8 @@ function router() {
|
|
|
267
268
|
|
|
268
269
|
api.get("/logo.png", (req, res) => {
|
|
269
270
|
res.type("image/png");
|
|
270
|
-
|
|
271
|
+
// Logo geliştirme sırasında değişmiyor; her gezinmede yeniden indirmesin.
|
|
272
|
+
res.setHeader("Cache-Control", "public, max-age=86400");
|
|
271
273
|
fs.createReadStream(LOGO_FILE).pipe(res);
|
|
272
274
|
});
|
|
273
275
|
|
|
@@ -293,6 +295,7 @@ function router() {
|
|
|
293
295
|
boot: BOOT_ID,
|
|
294
296
|
uptime: process.uptime(),
|
|
295
297
|
node: process.version,
|
|
298
|
+
version: versionStatus(),
|
|
296
299
|
memory: { rss: usage.rss, heapUsed: usage.heapUsed },
|
|
297
300
|
prewarm: { ...prewarmProgress },
|
|
298
301
|
requests: requests.slice(-25).reverse(),
|
|
@@ -351,7 +354,7 @@ function router() {
|
|
|
351
354
|
origin: `${req.protocol}://${req.get("host")}`,
|
|
352
355
|
paths: paths.length ? paths : undefined,
|
|
353
356
|
}).catch((error) => {
|
|
354
|
-
console.error("[prewarm]
|
|
357
|
+
console.error("[prewarm] manual trigger failed", error);
|
|
355
358
|
});
|
|
356
359
|
|
|
357
360
|
res.json({ ok: true, scope: paths.length || "all" });
|
|
@@ -378,6 +381,7 @@ export function mountDevtools(app) {
|
|
|
378
381
|
patchConsole();
|
|
379
382
|
trackServerFetch();
|
|
380
383
|
watchManifest();
|
|
384
|
+
startVersionCheck();
|
|
381
385
|
app.use(timing());
|
|
382
386
|
app.use(brand.devBasePath, router());
|
|
383
387
|
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* npm kayıt defterindeki son sürümü yoklar ve dev overlay'e "outdated" bilgisi
|
|
3
|
+
* verir. Yalnızca geliştirme sırasında çalışır.
|
|
4
|
+
*
|
|
5
|
+
* Sonuç geçici dizinde saklanıyor: `node --watch` sunucuyu sık yeniden
|
|
6
|
+
* başlatıyor ve her açılışta ağa çıkmak hem yavaş hem gereksiz. Kayıt defteri
|
|
7
|
+
* ulaşılamazsa sessizce eski/boş sonuç kullanılır — sürüm kontrolü hiçbir
|
|
8
|
+
* koşulda dev akışını bekletmez ya da hata basmaz.
|
|
9
|
+
*/
|
|
10
|
+
import fs from "node:fs";
|
|
11
|
+
import os from "node:os";
|
|
12
|
+
import path from "node:path";
|
|
13
|
+
import { FRAMEWORK_PACKAGE, FRAMEWORK_VERSION } from "../../version.mjs";
|
|
14
|
+
|
|
15
|
+
const CACHE_FILE = path.join(os.tmpdir(), `jskelet-version-${FRAMEWORK_PACKAGE}.json`);
|
|
16
|
+
|
|
17
|
+
/** Aynı sürümü saatte birden fazla sormanın anlamı yok. */
|
|
18
|
+
const TTL = 6 * 60 * 60 * 1000;
|
|
19
|
+
|
|
20
|
+
const TIMEOUT = 3000;
|
|
21
|
+
|
|
22
|
+
/** @type {{ current: string, latest: string | null, outdated: boolean, checkedAt: number | null }} */
|
|
23
|
+
let state = {
|
|
24
|
+
current: FRAMEWORK_VERSION,
|
|
25
|
+
latest: null,
|
|
26
|
+
outdated: false,
|
|
27
|
+
checkedAt: null,
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Ön sürüm etiketleri (`1.2.0-beta.1`) karşılaştırmada yok sayılır: kayıt
|
|
32
|
+
* defterinden yalnızca `latest` etiketi okunduğu için pratikte gelmiyor.
|
|
33
|
+
* @param {string} a
|
|
34
|
+
* @param {string} b
|
|
35
|
+
* @returns {number} a > b ise pozitif
|
|
36
|
+
*/
|
|
37
|
+
function compare(a, b) {
|
|
38
|
+
const parse = (value) =>
|
|
39
|
+
String(value)
|
|
40
|
+
.split("-")[0]
|
|
41
|
+
.split(".")
|
|
42
|
+
.map((part) => Number.parseInt(part, 10) || 0);
|
|
43
|
+
|
|
44
|
+
const left = parse(a);
|
|
45
|
+
const right = parse(b);
|
|
46
|
+
|
|
47
|
+
for (let index = 0; index < 3; index += 1) {
|
|
48
|
+
const diff = (left[index] ?? 0) - (right[index] ?? 0);
|
|
49
|
+
if (diff) return diff;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return 0;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* @param {string | null} latest
|
|
57
|
+
*/
|
|
58
|
+
function apply(latest) {
|
|
59
|
+
state = {
|
|
60
|
+
current: FRAMEWORK_VERSION,
|
|
61
|
+
latest,
|
|
62
|
+
outdated: Boolean(latest) && compare(latest, FRAMEWORK_VERSION) > 0,
|
|
63
|
+
checkedAt: Date.now(),
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** @returns {{ latest: string, checkedAt: number } | null} */
|
|
68
|
+
function readCache() {
|
|
69
|
+
try {
|
|
70
|
+
const saved = JSON.parse(fs.readFileSync(CACHE_FILE, "utf8"));
|
|
71
|
+
if (typeof saved.latest !== "string" || typeof saved.checkedAt !== "number") return null;
|
|
72
|
+
return saved;
|
|
73
|
+
} catch {
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async function fetchLatest() {
|
|
79
|
+
const controller = new AbortController();
|
|
80
|
+
const timer = setTimeout(() => controller.abort(), TIMEOUT);
|
|
81
|
+
|
|
82
|
+
try {
|
|
83
|
+
const response = await fetch(
|
|
84
|
+
`https://registry.npmjs.org/${encodeURIComponent(FRAMEWORK_PACKAGE)}/latest`,
|
|
85
|
+
{
|
|
86
|
+
signal: controller.signal,
|
|
87
|
+
headers: { accept: "application/vnd.npm.install-v1+json, application/json" },
|
|
88
|
+
},
|
|
89
|
+
);
|
|
90
|
+
|
|
91
|
+
if (!response.ok) return;
|
|
92
|
+
|
|
93
|
+
const body = await response.json();
|
|
94
|
+
if (typeof body?.version !== "string") return;
|
|
95
|
+
|
|
96
|
+
apply(body.version);
|
|
97
|
+
try {
|
|
98
|
+
fs.writeFileSync(
|
|
99
|
+
CACHE_FILE,
|
|
100
|
+
JSON.stringify({ latest: body.version, checkedAt: state.checkedAt }),
|
|
101
|
+
);
|
|
102
|
+
} catch {
|
|
103
|
+
// Önbellek yazılamazsa yalnızca her açılışta yeniden sorulur.
|
|
104
|
+
}
|
|
105
|
+
} catch {
|
|
106
|
+
// Ağ yok, kayıt defteri kapalı ya da zaman aşımı: sürüm bilgisi boş kalır.
|
|
107
|
+
} finally {
|
|
108
|
+
clearTimeout(timer);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Kontrolü başlatır. Beklenmez; sonuç geldiğinde `versionStatus()` günceldir.
|
|
114
|
+
* `JSKELET_VERSION_CHECK=0` ile tamamen kapatılabilir (çevrimdışı çalışma,
|
|
115
|
+
* kurumsal ağlar).
|
|
116
|
+
*/
|
|
117
|
+
export function startVersionCheck() {
|
|
118
|
+
if (process.env.JSKELET_VERSION_CHECK === "0") return;
|
|
119
|
+
|
|
120
|
+
const cached = readCache();
|
|
121
|
+
if (cached) {
|
|
122
|
+
apply(cached.latest);
|
|
123
|
+
state.checkedAt = cached.checkedAt;
|
|
124
|
+
if (Date.now() - cached.checkedAt < TTL) return;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Açılışta ağ isteği ilk isteğin önüne geçmesin.
|
|
128
|
+
const timer = setTimeout(() => {
|
|
129
|
+
fetchLatest();
|
|
130
|
+
}, 1500);
|
|
131
|
+
timer.unref?.();
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* @returns {{ current: string, latest: string | null, outdated: boolean, checkedAt: number | null }}
|
|
136
|
+
*/
|
|
137
|
+
export function versionStatus() {
|
|
138
|
+
return state;
|
|
139
|
+
}
|
package/src/server/head-hints.js
CHANGED
|
@@ -29,7 +29,7 @@ export function preconnectHints() {
|
|
|
29
29
|
try {
|
|
30
30
|
return `<link rel="preconnect" href="${esc(new URL(origin).origin)}">`;
|
|
31
31
|
} catch {
|
|
32
|
-
console.warn(`[head] preconnect
|
|
32
|
+
console.warn(`[head] invalid preconnect URL, skipped: ${origin}`);
|
|
33
33
|
return "";
|
|
34
34
|
}
|
|
35
35
|
})
|
package/src/server/html-cache.js
CHANGED
|
@@ -83,8 +83,10 @@ function write(key, value, ttlSeconds) {
|
|
|
83
83
|
/**
|
|
84
84
|
* @param {string} key
|
|
85
85
|
* @param {number} ttlSeconds
|
|
86
|
-
* @param {() => Promise<{ html: string, status: number, degraded?: boolean
|
|
87
|
-
*
|
|
86
|
+
* @param {() => Promise<{ html: string, status: number, degraded?: boolean,
|
|
87
|
+
* storable?: boolean }>} producer
|
|
88
|
+
* @returns {Promise<{ html: string, status: number, degraded?: boolean,
|
|
89
|
+
* storable?: boolean }>}
|
|
88
90
|
*/
|
|
89
91
|
function refresh(key, ttlSeconds, producer) {
|
|
90
92
|
const pending = inflight.get(key);
|
|
@@ -94,7 +96,11 @@ function refresh(key, ttlSeconds, producer) {
|
|
|
94
96
|
.then((value) => {
|
|
95
97
|
// `degraded`: upstream düştüğü için eksik veriyle üretilmiş HTML.
|
|
96
98
|
// Saklanırsa eksik içerik tüm TTL boyunca servis edilir.
|
|
97
|
-
|
|
99
|
+
//
|
|
100
|
+
// `storable: false`: çıktı kullanıcıya bağlı (cookie/Authorization
|
|
101
|
+
// okundu). Anahtar yalnızca yol + query olduğu için saklamak, bir
|
|
102
|
+
// kullanıcının HTML'ini bir başkasına servis etmek olur.
|
|
103
|
+
if (value.status === 200 && !value.degraded && value.storable !== false) {
|
|
98
104
|
write(key, value, ttlSeconds);
|
|
99
105
|
}
|
|
100
106
|
return value;
|
|
@@ -127,7 +133,7 @@ export async function withHtmlCache(key, ttlSeconds, producer) {
|
|
|
127
133
|
// isteği etkilemez (eski HTML stale penceresi boyunca geçerli kalır).
|
|
128
134
|
if (hit.stale) {
|
|
129
135
|
void refresh(key, ttlSeconds, producer).catch((error) => {
|
|
130
|
-
console.error(`[html-cache]
|
|
136
|
+
console.error(`[html-cache] background refresh failed: ${key}`, error);
|
|
131
137
|
});
|
|
132
138
|
}
|
|
133
139
|
return { ...hit, cached: true };
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CSRF koruması.
|
|
3
|
+
*
|
|
4
|
+
* Bu yüzey framework'e ait, çünkü gövdeyi ayrıştıran o: `create-app.js`
|
|
5
|
+
* `express.urlencoded` ve `express.json` kuruyor, yani state değiştiren
|
|
6
|
+
* istekleri kabul eden katman framework. Cookie ile kimlik doğrulayan bir
|
|
7
|
+
* uygulama bu koruma olmadan açık.
|
|
8
|
+
*
|
|
9
|
+
* İki katman:
|
|
10
|
+
*
|
|
11
|
+
* 1. **Origin kontrolü** (varsayılan açık). `Origin` ya da
|
|
12
|
+
* `Sec-Fetch-Site` başlığı çapraz site olduğunu gösteriyorsa istek
|
|
13
|
+
* reddedilir. Başlıkların **hiçbiri yoksa geçer**: tarayıcılar çapraz
|
|
14
|
+
* origin bir POST'ta `Origin`'i her zaman gönderir, buna karşılık
|
|
15
|
+
* webhook'lar ve sunucudan sunucuya çağrılar hiç göndermez. Bu ayrım,
|
|
16
|
+
* korumayı açık bırakırken entegrasyonları bozmamayı sağlıyor.
|
|
17
|
+
*
|
|
18
|
+
* 2. **Çift gönderim token'ı** (`security.csrf.token` ile açılır). Cookie'deki
|
|
19
|
+
* imzalı token ile form alanı/başlıktaki değer karşılaştırılır. `Origin`
|
|
20
|
+
* göndermeyen eski tarayıcılar için ikinci katman; formlara `csrfField()`
|
|
21
|
+
* eklenmesini gerektirdiği için varsayılan kapalı.
|
|
22
|
+
*
|
|
23
|
+
* Token'ı bu middleware **üretmez**, yalnızca doğrular. Üretim `csrfField()`
|
|
24
|
+
* içinde, yani gerçekten bir forma basıldığı anda olur. Sebebi somut: token
|
|
25
|
+
* her yanıtta yazılsaydı public ve cache'lenebilir bir sayfa da `Set-Cookie`
|
|
26
|
+
* taşırdı, bir CDN o yanıtı saklardı ve tüm ziyaretçiler aynı token'ı
|
|
27
|
+
* paylaşırdı — çift gönderim kontrolü tam olarak o noktada anlamını yitirir.
|
|
28
|
+
*/
|
|
29
|
+
import { getConfig } from "../../config/index.js";
|
|
30
|
+
import { matchPattern } from "../../config/pattern.js";
|
|
31
|
+
import { getSignedCookie, safeEqual } from "../../http/cookies.js";
|
|
32
|
+
|
|
33
|
+
/** Gövdesi olmayan, yan etkisi beklenmeyen metotlar. */
|
|
34
|
+
const SAFE_METHODS = new Set(["GET", "HEAD", "OPTIONS"]);
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* İsteğin geldiği origin'in kendi origin'imiz olup olmadığını söyler.
|
|
38
|
+
*
|
|
39
|
+
* `trust proxy` kapalıysa `req.protocol` her zaman `http` döner ve HTTPS
|
|
40
|
+
* arkasında her istek çapraz site sanılır; bu yüzden karşılaştırma yalnızca
|
|
41
|
+
* host üzerinden yapılır, protokol değil.
|
|
42
|
+
*
|
|
43
|
+
* @param {import('express').Request} req
|
|
44
|
+
* @param {string[]} allowedOrigins
|
|
45
|
+
* @returns {boolean}
|
|
46
|
+
*/
|
|
47
|
+
function isSameOrigin(req, allowedOrigins) {
|
|
48
|
+
const origin = req.headers.origin;
|
|
49
|
+
if (!origin || origin === "null") return false;
|
|
50
|
+
|
|
51
|
+
if (allowedOrigins.includes(origin)) return true;
|
|
52
|
+
|
|
53
|
+
try {
|
|
54
|
+
const host = req.headers.host;
|
|
55
|
+
return Boolean(host) && new URL(origin).host === host;
|
|
56
|
+
} catch {
|
|
57
|
+
return false;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* @returns {import('express').RequestHandler}
|
|
63
|
+
*/
|
|
64
|
+
export function csrf() {
|
|
65
|
+
const { security } = getConfig();
|
|
66
|
+
const { csrf: options } = security;
|
|
67
|
+
|
|
68
|
+
return (req, res, next) => {
|
|
69
|
+
if (!options.enabled) {
|
|
70
|
+
next();
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const pathname = req.path ?? "";
|
|
75
|
+
if (options.exclude.some((pattern) => matchPattern(pattern, pathname))) {
|
|
76
|
+
next();
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (SAFE_METHODS.has(req.method)) {
|
|
81
|
+
next();
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const site = String(req.headers["sec-fetch-site"] ?? "");
|
|
86
|
+
const hasOrigin = Boolean(req.headers.origin);
|
|
87
|
+
|
|
88
|
+
// Yalnızca çapraz site olduğu **belli** olan istekler reddedilir.
|
|
89
|
+
if (site === "cross-site" || (hasOrigin && !isSameOrigin(req, options.allowedOrigins))) {
|
|
90
|
+
reject(req, res, "origin mismatch");
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if (options.token) {
|
|
95
|
+
// Cookie yoksa istek zaten token basan bir formdan gelmiyor.
|
|
96
|
+
const expected = getSignedCookie(req, options.cookieName);
|
|
97
|
+
const provided =
|
|
98
|
+
firstString(/** @type {any} */ (req.body)?.[options.fieldName]) ??
|
|
99
|
+
firstString(req.headers[options.headerName]);
|
|
100
|
+
|
|
101
|
+
if (!expected || !provided || !safeEqual(provided, expected)) {
|
|
102
|
+
reject(req, res, "token mismatch");
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
next();
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* @param {unknown} value
|
|
113
|
+
* @returns {string | null}
|
|
114
|
+
*/
|
|
115
|
+
function firstString(value) {
|
|
116
|
+
if (typeof value === "string" && value) return value;
|
|
117
|
+
if (Array.isArray(value)) return firstString(value[0]);
|
|
118
|
+
return null;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Reddedilen istek HTML sayfası değil düz metin döner: bu bir kullanıcı
|
|
123
|
+
* hatası değil, ya bir saldırı ya da bir programlama hatası — ve fragment
|
|
124
|
+
* takasında bir hata sayfasının içine düşmemeli.
|
|
125
|
+
*
|
|
126
|
+
* @param {import('express').Request} req
|
|
127
|
+
* @param {import('express').Response} res
|
|
128
|
+
* @param {string} reason
|
|
129
|
+
*/
|
|
130
|
+
function reject(req, res, reason) {
|
|
131
|
+
console.warn(`[csrf] ${req.method} ${req.originalUrl} rejected — ${reason}`);
|
|
132
|
+
res.status(403).setHeader("Cache-Control", "no-store");
|
|
133
|
+
res.type("text/plain").send("Forbidden");
|
|
134
|
+
}
|
package/src/server/prewarm.js
CHANGED
|
@@ -64,7 +64,7 @@ async function collectPaths() {
|
|
|
64
64
|
const paths = await hook("prewarmPaths", []);
|
|
65
65
|
|
|
66
66
|
if (!Array.isArray(paths)) {
|
|
67
|
-
console.warn("[prewarm] hooks.prewarmPaths()
|
|
67
|
+
console.warn("[prewarm] hooks.prewarmPaths() must return an array, ignoring it");
|
|
68
68
|
return [];
|
|
69
69
|
}
|
|
70
70
|
|
|
@@ -237,10 +237,10 @@ export async function prewarm({ origin, quiet = false, paths: only }) {
|
|
|
237
237
|
if (!quiet && paths.length) {
|
|
238
238
|
const skipped = all.length - paths.length;
|
|
239
239
|
console.log(
|
|
240
|
-
`[prewarm] ${ok}/${paths.length}
|
|
241
|
-
`${failed ? `, ${failed}
|
|
242
|
-
`${recovered ? `, ${recovered}
|
|
243
|
-
`${skipped > 0 ? `, ${skipped}
|
|
240
|
+
`[prewarm] warmed ${ok}/${paths.length} pages` +
|
|
241
|
+
`${failed ? `, ${failed} failed` : ""}` +
|
|
242
|
+
`${recovered ? `, ${recovered} recovered on the retry pass` : ""}` +
|
|
243
|
+
`${skipped > 0 ? `, ${skipped} over the limit` : ""}` +
|
|
244
244
|
` (${(elapsed / 1000).toFixed(1)}s)`,
|
|
245
245
|
);
|
|
246
246
|
}
|
|
@@ -266,7 +266,7 @@ export function startPrewarm({ port }) {
|
|
|
266
266
|
const origin = `http://127.0.0.1:${port}`;
|
|
267
267
|
const run = () =>
|
|
268
268
|
prewarm({ origin }).catch((error) => {
|
|
269
|
-
console.error("[prewarm]
|
|
269
|
+
console.error("[prewarm] failed", error);
|
|
270
270
|
});
|
|
271
271
|
|
|
272
272
|
// Isıtma ilk isteklerle yarışmasın diye gecikmeyle başlar. Dev'de gecikme
|