jskelet 0.1.1 → 0.1.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +5 -0
- package/CHANGELOG.md +129 -2
- 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 +240 -26
- package/docs/07-yapilandirma.md +108 -7
- 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 +640 -0
- package/docs/en/07-configuration.md +789 -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 +83 -0
- package/src/config/index.js +129 -18
- 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 +26 -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/data-cache.js +244 -0
- package/src/server/dev/devtools.js +6 -2
- package/src/server/dev/report.js +8 -1
- package/src/server/dev/version-check.mjs +139 -0
- package/src/server/head-hints.js +1 -1
- package/src/server/html-cache.js +32 -6
- package/src/server/middleware/csrf.js +134 -0
- package/src/server/prewarm.js +164 -19
- package/src/server/render.js +256 -20
- 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
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Upstream veri için TTL + stale-while-revalidate LRU önbelleği.
|
|
3
|
+
*
|
|
4
|
+
* HTML önbelleği (`html-cache.js`) yalnızca **trafiği olan** sayfaları tutar:
|
|
5
|
+
* girdi başına yüz kilobayt düştüğü için sınırı 500 civarındadır ve on binlerce
|
|
6
|
+
* yolluk bir site onu ısıtmaya çalıştığında kendi ısıttığını siler. Uzun kuyruk
|
|
7
|
+
* için doğru katman bu modül: aynı sayfanın JSON'u HTML'inden onlarca kat
|
|
8
|
+
* küçük olduğu için on binlerce girdi bellekte durur.
|
|
9
|
+
*
|
|
10
|
+
* Kazanç iki taraflı:
|
|
11
|
+
* - Hiç ısıtılmamış bir uzun kuyruk sayfası ilk ziyaretçide render edilir ama
|
|
12
|
+
* upstream'e gitmez; gecikme yüzlerce ms değil, şablon render'ı kadardır.
|
|
13
|
+
* - Periyodik ısıtma turları API kotası harcamaz, veri katmanından okur.
|
|
14
|
+
*
|
|
15
|
+
* `null`/`undefined` **saklanmaz**: uygulamaların HTTP istemcisi hatada
|
|
16
|
+
* genellikle `null` döner ve bunu saklamak, geçici bir 429'u TTL boyunca "veri
|
|
17
|
+
* yok" hâline dondurmak olurdu. Boş cevabı bilinçli olarak saklamak isteyen
|
|
18
|
+
* `storeEmpty: true` verir.
|
|
19
|
+
*/
|
|
20
|
+
import { getConfig } from "../config/index.js";
|
|
21
|
+
import { DEFAULT_DATA_CACHE } from "../config/defaults.js";
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* @typedef {{ value: unknown, expiresAt: number, staleUntil: number }} DataEntry
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
/** @type {Map<string, DataEntry>} */
|
|
28
|
+
const store = new Map();
|
|
29
|
+
|
|
30
|
+
/** @type {Map<string, Promise<unknown>>} */
|
|
31
|
+
const inflight = new Map();
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Ayarlar config'ten okunur ama config yüklenmemiş olabilir: bu modül
|
|
35
|
+
* script'lerden ve testlerden de çağrılabiliyor. `getConfig()` fırlatırsa
|
|
36
|
+
* kod varsayılanına düşülür.
|
|
37
|
+
*
|
|
38
|
+
* @returns {{ maxEntries: number, staleFactor: number }}
|
|
39
|
+
*/
|
|
40
|
+
function settings() {
|
|
41
|
+
try {
|
|
42
|
+
const { data } = getConfig();
|
|
43
|
+
return {
|
|
44
|
+
maxEntries: Number(data?.maxEntries) || DEFAULT_DATA_CACHE.maxEntries,
|
|
45
|
+
staleFactor: Number.isFinite(Number(data?.staleFactor))
|
|
46
|
+
? Number(data.staleFactor)
|
|
47
|
+
: DEFAULT_DATA_CACHE.staleFactor,
|
|
48
|
+
};
|
|
49
|
+
} catch {
|
|
50
|
+
return { ...DEFAULT_DATA_CACHE };
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* @param {string} key
|
|
56
|
+
* @returns {{ value: unknown, stale: boolean } | null}
|
|
57
|
+
*/
|
|
58
|
+
function read(key) {
|
|
59
|
+
const entry = store.get(key);
|
|
60
|
+
if (!entry) return null;
|
|
61
|
+
|
|
62
|
+
const now = Date.now();
|
|
63
|
+
if (now >= entry.staleUntil) {
|
|
64
|
+
store.delete(key);
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// LRU: erişilen girdiyi sona taşı.
|
|
69
|
+
store.delete(key);
|
|
70
|
+
store.set(key, entry);
|
|
71
|
+
|
|
72
|
+
return { value: entry.value, stale: now >= entry.expiresAt };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* @param {string} key
|
|
77
|
+
* @param {unknown} value
|
|
78
|
+
* @param {number} ttlSeconds
|
|
79
|
+
* @param {number} staleFactor
|
|
80
|
+
*/
|
|
81
|
+
function write(key, value, ttlSeconds, staleFactor) {
|
|
82
|
+
const now = Date.now();
|
|
83
|
+
const ttl = ttlSeconds * 1000;
|
|
84
|
+
|
|
85
|
+
store.set(key, {
|
|
86
|
+
value,
|
|
87
|
+
expiresAt: now + ttl,
|
|
88
|
+
staleUntil: now + ttl + ttl * staleFactor,
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
const { maxEntries } = settings();
|
|
92
|
+
while (store.size > maxEntries) {
|
|
93
|
+
const oldest = store.keys().next().value;
|
|
94
|
+
if (oldest === undefined) break;
|
|
95
|
+
store.delete(oldest);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* @param {string} key
|
|
101
|
+
* @param {number} ttlSeconds
|
|
102
|
+
* @param {() => Promise<unknown>} producer
|
|
103
|
+
* @param {{ storeEmpty?: boolean, staleFactor?: number }} options
|
|
104
|
+
* @returns {Promise<unknown>}
|
|
105
|
+
*/
|
|
106
|
+
function refresh(key, ttlSeconds, producer, options) {
|
|
107
|
+
// Aynı anahtarı eşzamanlı isteyen yüz sayfa tek upstream isteğine düşer.
|
|
108
|
+
// Isıtma turlarında bu tek başına kotanın büyük kısmını kurtarıyor.
|
|
109
|
+
const pending = inflight.get(key);
|
|
110
|
+
if (pending) return pending;
|
|
111
|
+
|
|
112
|
+
const staleFactor = options.staleFactor ?? settings().staleFactor;
|
|
113
|
+
|
|
114
|
+
const task = Promise.resolve()
|
|
115
|
+
.then(producer)
|
|
116
|
+
.then((value) => {
|
|
117
|
+
const empty = value === undefined || value === null;
|
|
118
|
+
if (!empty || options.storeEmpty === true) {
|
|
119
|
+
write(key, value, ttlSeconds, staleFactor);
|
|
120
|
+
}
|
|
121
|
+
return value;
|
|
122
|
+
})
|
|
123
|
+
.finally(() => {
|
|
124
|
+
inflight.delete(key);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
inflight.set(key, task);
|
|
128
|
+
return task;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Veriyi önbellekten döner, gerekiyorsa `producer` ile üretir.
|
|
133
|
+
*
|
|
134
|
+
* @param {string} key Anahtar tamamen uygulamanın; sürüm/dil gibi ayrımlar
|
|
135
|
+
* anahtara yazılır (`quote:v2:${symbol}`).
|
|
136
|
+
* @param {number} ttlSeconds 0 → önbellek yok, `producer` her çağrıda çalışır.
|
|
137
|
+
* @param {() => Promise<T>} producer
|
|
138
|
+
* @param {{ storeEmpty?: boolean, staleFactor?: number }} [options]
|
|
139
|
+
* `storeEmpty` boş cevabı da saklar, `staleFactor` bu anahtar için bayat
|
|
140
|
+
* penceresini ayarlar (0 → bayat servis yok).
|
|
141
|
+
* @returns {Promise<T>}
|
|
142
|
+
* @template T
|
|
143
|
+
*/
|
|
144
|
+
export async function withDataCache(key, ttlSeconds, producer, options = {}) {
|
|
145
|
+
if (!ttlSeconds) return producer();
|
|
146
|
+
|
|
147
|
+
const hit = read(key);
|
|
148
|
+
|
|
149
|
+
if (hit) {
|
|
150
|
+
// Bayat girdi anında döner; tazeleme arkada yürür ve hatası bu isteği
|
|
151
|
+
// etkilemez — çağıran taraf bir şey beklemediği için upstream'in yavaş
|
|
152
|
+
// olması sayfaya yansımaz.
|
|
153
|
+
if (hit.stale) {
|
|
154
|
+
void refresh(key, ttlSeconds, producer, options).catch((error) => {
|
|
155
|
+
console.error(`[data-cache] background refresh failed: ${key}`, error);
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
return /** @type {T} */ (hit.value);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
try {
|
|
162
|
+
return /** @type {T} */ (await refresh(key, ttlSeconds, producer, options));
|
|
163
|
+
} catch (error) {
|
|
164
|
+
// Girdi yoksa hata çağırana gider; asıl kazanç bayat girdinin olduğu
|
|
165
|
+
// durumda: upstream düşmüşken sayfayı eski veriyle ayakta tutmak,
|
|
166
|
+
// ziyaretçiye hata sayfası göstermekten iyidir.
|
|
167
|
+
const stale = read(key);
|
|
168
|
+
if (!stale) throw error;
|
|
169
|
+
|
|
170
|
+
console.warn(
|
|
171
|
+
`[data-cache] producer failed, serving stale value: ${key}`,
|
|
172
|
+
error instanceof Error ? error.message : error,
|
|
173
|
+
);
|
|
174
|
+
return /** @type {T} */ (stale.value);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* `withDataCache`'in fonksiyon sarmalayıcısı: argümanlardan anahtar üretir.
|
|
180
|
+
* `cache()` (istek içi memoizasyon) ile aynı kullanım biçimi, ama istekler
|
|
181
|
+
* arasında ve TTL'li.
|
|
182
|
+
*
|
|
183
|
+
* @param {F} fn
|
|
184
|
+
* @param {{ key: string, revalidate: number, storeEmpty?: boolean,
|
|
185
|
+
* staleFactor?: number }} options `key` önektir; argümanlar sonuna eklenir.
|
|
186
|
+
* @returns {F}
|
|
187
|
+
* @template {(...args: any[]) => Promise<any>} F
|
|
188
|
+
*/
|
|
189
|
+
export function dataCache(fn, options) {
|
|
190
|
+
const wrapped = (...args) =>
|
|
191
|
+
withDataCache(
|
|
192
|
+
args.length ? `${options.key}:${JSON.stringify(args)}` : options.key,
|
|
193
|
+
options.revalidate,
|
|
194
|
+
() => fn(...args),
|
|
195
|
+
options,
|
|
196
|
+
);
|
|
197
|
+
|
|
198
|
+
return /** @type {F} */ (wrapped);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Bir anahtarı ya da önek eşleşen tüm anahtarları düşürür. Webhook ile
|
|
203
|
+
* "bu haber güncellendi" bilgisi geldiğinde kullanılır.
|
|
204
|
+
*
|
|
205
|
+
* @param {string} [prefix] Verilmezse tüm önbellek boşaltılır.
|
|
206
|
+
* @returns {number} Silinen girdi sayısı.
|
|
207
|
+
*/
|
|
208
|
+
export function clearDataCache(prefix) {
|
|
209
|
+
if (prefix === undefined) {
|
|
210
|
+
const size = store.size;
|
|
211
|
+
store.clear();
|
|
212
|
+
return size;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
let removed = 0;
|
|
216
|
+
for (const key of store.keys()) {
|
|
217
|
+
if (key.startsWith(prefix)) {
|
|
218
|
+
store.delete(key);
|
|
219
|
+
removed += 1;
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
return removed;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/** @returns {number} */
|
|
226
|
+
export function getDataCacheSize() {
|
|
227
|
+
return store.size;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Dev raporu ve yönetim uçları için döküm. Değerin kendisi dönmez: JSON'un
|
|
232
|
+
* tamamını bir teşhis ucundan dışa vermek istenmez.
|
|
233
|
+
*
|
|
234
|
+
* @returns {{ key: string, stale: boolean, expiresIn: number }[]}
|
|
235
|
+
*/
|
|
236
|
+
export function getDataCacheEntries() {
|
|
237
|
+
const now = Date.now();
|
|
238
|
+
|
|
239
|
+
return [...store.entries()].map(([key, entry]) => ({
|
|
240
|
+
key,
|
|
241
|
+
stale: now >= entry.expiresAt,
|
|
242
|
+
expiresIn: Math.round((entry.expiresAt - now) / 1000),
|
|
243
|
+
}));
|
|
244
|
+
}
|
|
@@ -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
|
}
|
package/src/server/dev/report.js
CHANGED
|
@@ -13,6 +13,7 @@ import fs from "node:fs";
|
|
|
13
13
|
import path from "node:path";
|
|
14
14
|
import zlib from "node:zlib";
|
|
15
15
|
import { getHtmlCacheEntries, getHtmlCacheSize } from "../html-cache.js";
|
|
16
|
+
import { getDataCacheSize } from "../data-cache.js";
|
|
16
17
|
import { prewarmProgress } from "../prewarm.js";
|
|
17
18
|
import { getConfig } from "../../config/index.js";
|
|
18
19
|
|
|
@@ -343,7 +344,13 @@ export function buildReport(devtools) {
|
|
|
343
344
|
pages: pageList.sort((a, b) => (b.visits - a.visits) || b.at - a.at),
|
|
344
345
|
serverApi: serverApiCalls.slice().reverse(),
|
|
345
346
|
build: { assets: assets(), ...chunks() },
|
|
346
|
-
cache: {
|
|
347
|
+
cache: {
|
|
348
|
+
size: getHtmlCacheSize(),
|
|
349
|
+
entries: getHtmlCacheEntries(),
|
|
350
|
+
// Veri önbelleğinden yalnızca sayaç: uzun kuyruklu bir sitede on
|
|
351
|
+
// binlerce anahtar oluyor ve dökümü rapora koymak faydasız bir yük.
|
|
352
|
+
data: getDataCacheSize(),
|
|
353
|
+
},
|
|
347
354
|
prewarm: { ...prewarmProgress },
|
|
348
355
|
requests: devtools.requests,
|
|
349
356
|
errors: devtools.errors,
|
|
@@ -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
|
@@ -8,12 +8,31 @@
|
|
|
8
8
|
* WebSocket'ten güncellendiği için bu gecikme ekranda görünmez.
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
+
import { getConfig } from "../config/index.js";
|
|
12
|
+
import { DEFAULT_HTML_CACHE_MAX_ENTRIES } from "../config/defaults.js";
|
|
13
|
+
|
|
11
14
|
/**
|
|
12
15
|
* @typedef {{ html: string, status: number, expiresAt: number,
|
|
13
16
|
* staleUntil: number, encoded: Map<string, Buffer> }} HtmlEntry
|
|
14
17
|
*/
|
|
15
18
|
|
|
16
|
-
|
|
19
|
+
/**
|
|
20
|
+
* Girdi sınırı `cache().maxEntries` ile yükseltilebilir ama uzun kuyruklu bir
|
|
21
|
+
* siteyi buradan çözmeye çalışmak yanlış katman: girdi başına yüz kilobayt
|
|
22
|
+
* düşüyor. On binlerce yol için `withDataCache` kullanılır.
|
|
23
|
+
*
|
|
24
|
+
* Config yüklenmemiş olabilir (testler bu modülü doğrudan çağırıyor); o
|
|
25
|
+
* durumda kod varsayılanı geçerli.
|
|
26
|
+
*
|
|
27
|
+
* @returns {number}
|
|
28
|
+
*/
|
|
29
|
+
function maxEntries() {
|
|
30
|
+
try {
|
|
31
|
+
return getConfig().htmlMaxEntries;
|
|
32
|
+
} catch {
|
|
33
|
+
return DEFAULT_HTML_CACHE_MAX_ENTRIES;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
17
36
|
|
|
18
37
|
/**
|
|
19
38
|
* TTL dolduktan sonra eski HTML'in kaç TTL boyunca daha servis edilebileceği.
|
|
@@ -73,7 +92,8 @@ function write(key, value, ttlSeconds) {
|
|
|
73
92
|
staleUntil: now + ttlSeconds * 1000 * (1 + STALE_FACTOR),
|
|
74
93
|
});
|
|
75
94
|
|
|
76
|
-
|
|
95
|
+
const limit = maxEntries();
|
|
96
|
+
while (store.size > limit) {
|
|
77
97
|
const oldest = store.keys().next().value;
|
|
78
98
|
if (oldest === undefined) break;
|
|
79
99
|
store.delete(oldest);
|
|
@@ -83,8 +103,10 @@ function write(key, value, ttlSeconds) {
|
|
|
83
103
|
/**
|
|
84
104
|
* @param {string} key
|
|
85
105
|
* @param {number} ttlSeconds
|
|
86
|
-
* @param {() => Promise<{ html: string, status: number, degraded?: boolean
|
|
87
|
-
*
|
|
106
|
+
* @param {() => Promise<{ html: string, status: number, degraded?: boolean,
|
|
107
|
+
* storable?: boolean }>} producer
|
|
108
|
+
* @returns {Promise<{ html: string, status: number, degraded?: boolean,
|
|
109
|
+
* storable?: boolean }>}
|
|
88
110
|
*/
|
|
89
111
|
function refresh(key, ttlSeconds, producer) {
|
|
90
112
|
const pending = inflight.get(key);
|
|
@@ -94,7 +116,11 @@ function refresh(key, ttlSeconds, producer) {
|
|
|
94
116
|
.then((value) => {
|
|
95
117
|
// `degraded`: upstream düştüğü için eksik veriyle üretilmiş HTML.
|
|
96
118
|
// Saklanırsa eksik içerik tüm TTL boyunca servis edilir.
|
|
97
|
-
|
|
119
|
+
//
|
|
120
|
+
// `storable: false`: çıktı kullanıcıya bağlı (cookie/Authorization
|
|
121
|
+
// okundu). Anahtar yalnızca yol + query olduğu için saklamak, bir
|
|
122
|
+
// kullanıcının HTML'ini bir başkasına servis etmek olur.
|
|
123
|
+
if (value.status === 200 && !value.degraded && value.storable !== false) {
|
|
98
124
|
write(key, value, ttlSeconds);
|
|
99
125
|
}
|
|
100
126
|
return value;
|
|
@@ -127,7 +153,7 @@ export async function withHtmlCache(key, ttlSeconds, producer) {
|
|
|
127
153
|
// isteği etkilemez (eski HTML stale penceresi boyunca geçerli kalır).
|
|
128
154
|
if (hit.stale) {
|
|
129
155
|
void refresh(key, ttlSeconds, producer).catch((error) => {
|
|
130
|
-
console.error(`[html-cache]
|
|
156
|
+
console.error(`[html-cache] background refresh failed: ${key}`, error);
|
|
131
157
|
});
|
|
132
158
|
}
|
|
133
159
|
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
|
+
}
|