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/server/render.js
CHANGED
|
@@ -21,6 +21,12 @@ import { matchPattern } from "../config/pattern.js";
|
|
|
21
21
|
import { encodeText, negotiateEncoding } from "./middleware/compression.js";
|
|
22
22
|
import { navigationHints, preconnectHints } from "./head-hints.js";
|
|
23
23
|
import { withRequestCache } from "../http/request-cache.js";
|
|
24
|
+
import {
|
|
25
|
+
createRequestContext,
|
|
26
|
+
getRequestContext,
|
|
27
|
+
guardRequest,
|
|
28
|
+
withRequestContext,
|
|
29
|
+
} from "../http/request-context.js";
|
|
24
30
|
import { getUpstreamFailures, withUpstreamTracking } from "./upstream-tracking.js";
|
|
25
31
|
import { isNotFoundError, isRedirectError } from "../http/control-flow.js";
|
|
26
32
|
import { renderHeadMeta } from "./metadata.js";
|
|
@@ -153,50 +159,114 @@ export async function renderPage(page) {
|
|
|
153
159
|
);
|
|
154
160
|
}
|
|
155
161
|
|
|
162
|
+
/**
|
|
163
|
+
* Kişiye özel yanıtın cache direktifi. Dinamik (cache'lenmeyen) her sayfa da
|
|
164
|
+
* bunu alır: bir yanıt hiçbir direktif taşımadığında HTTP onu "sezgisel olarak
|
|
165
|
+
* cache'lenebilir" sayar ve araya giren bir proxy ya da tarayıcının geri
|
|
166
|
+
* tuşu kullanıcıya özel HTML'i saklayabilir.
|
|
167
|
+
*/
|
|
168
|
+
const PRIVATE_CACHE = "private, no-store";
|
|
169
|
+
|
|
156
170
|
/**
|
|
157
171
|
* Controller'ı çalıştırıp yanıtı yazar; notFound/redirect kontrol akışını,
|
|
158
172
|
* HTML cache'ini ve hata yönetimini üstlenir.
|
|
159
173
|
*
|
|
174
|
+
* `private: true` kişiye özel sayfaları public cache yolundan tamamen ayırır:
|
|
175
|
+
* HTML cache devre dışı kalır, config'in `cache.html` deseni bu kararı
|
|
176
|
+
* ezemez, yanıt `no-store` ile ve ETag'siz gider. Dashboard tipi sayfalarda
|
|
177
|
+
* bu bayrak olmadan çalışmak, bir kullanıcının HTML'inin bir başkasına
|
|
178
|
+
* servis edilmesi anlamına gelir.
|
|
179
|
+
*
|
|
160
180
|
* @param {(ctx: { params: object, query: object, pathname: string,
|
|
161
181
|
* req: import('express').Request }) => Promise<object>} controller
|
|
162
|
-
* @param {{ revalidate?: number }} [options]
|
|
182
|
+
* @param {{ revalidate?: number, private?: boolean }} [options]
|
|
163
183
|
* @returns {import('express').RequestHandler}
|
|
164
184
|
*/
|
|
165
185
|
export function route(controller, options = {}) {
|
|
186
|
+
const isPrivate = options.private === true;
|
|
187
|
+
|
|
166
188
|
return async (req, res, next) => {
|
|
189
|
+
const context = createRequestContext({ private: isPrivate, res });
|
|
167
190
|
const ctx = {
|
|
168
191
|
params: req.params ?? {},
|
|
169
192
|
query: req.query ?? {},
|
|
170
193
|
pathname: req.path,
|
|
171
|
-
|
|
194
|
+
// Cookie/Authorization okunursa çıktı kullanıcıya bağlıdır; cache'e
|
|
195
|
+
// yazılmaması için işaretlenmesi gerekiyor.
|
|
196
|
+
req: guardRequest(req),
|
|
172
197
|
};
|
|
173
198
|
|
|
174
|
-
|
|
175
|
-
|
|
199
|
+
// Private route'ta desen taraması hiç yapılmaz: `cache.html` altındaki
|
|
200
|
+
// geniş bir kural (`/**` gibi) bu sayfayı cache'lenebilir hâle
|
|
201
|
+
// getirmesin. Kilit tek yönlü — route "özel" dediyse config açamaz.
|
|
202
|
+
const revalidate = isPrivate
|
|
203
|
+
? undefined
|
|
204
|
+
: resolveRevalidate(req.path, options.revalidate);
|
|
205
|
+
const cacheable = !isPrivate && req.method === "GET" && Boolean(revalidate);
|
|
176
206
|
const cacheKey = `${req.path}?${new URLSearchParams(
|
|
177
207
|
Object.entries(ctx.query).map(([k, v]) => [k, String(v)]),
|
|
178
208
|
).toString()}`;
|
|
179
209
|
|
|
180
210
|
try {
|
|
181
|
-
const result = await
|
|
182
|
-
|
|
211
|
+
const result = await withRequestContext(context, () =>
|
|
212
|
+
withHtmlCache(cacheKey, cacheable ? revalidate : 0, () =>
|
|
213
|
+
withUpstreamTracking(() => withRequestCache(() => produce(controller, ctx))),
|
|
214
|
+
),
|
|
183
215
|
);
|
|
184
216
|
|
|
217
|
+
// Cache'lenebilir bir route kimliğe dokunduysa yanıt yine gider ama
|
|
218
|
+
// saklanmaz (`produce` bunu `storable: false` ile bildirdi) ve public
|
|
219
|
+
// direktif yazılmaz.
|
|
220
|
+
const leaked = cacheable && context.tainted;
|
|
221
|
+
if (leaked && isDev) {
|
|
222
|
+
throw new Error(
|
|
223
|
+
`[render] ${req.path} is a cacheable route but read identity-bound data ` +
|
|
224
|
+
`(${context.taintReasons.join(", ")}). This page must be registered with ` +
|
|
225
|
+
`'route(fn, { private: true })'; otherwise one user's HTML is served to another.`,
|
|
226
|
+
);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
const publicCache = cacheable && !leaked;
|
|
230
|
+
|
|
185
231
|
res.status(result.status);
|
|
186
232
|
res.setHeader("Content-Type", "text/html; charset=utf-8");
|
|
187
|
-
|
|
233
|
+
|
|
234
|
+
if (publicCache) {
|
|
188
235
|
res.setHeader(
|
|
189
236
|
"Cache-Control",
|
|
190
237
|
`public, max-age=0, s-maxage=${revalidate}, stale-while-revalidate=60`,
|
|
191
238
|
);
|
|
239
|
+
res.setHeader(
|
|
240
|
+
getConfig().brand.cacheHeader,
|
|
241
|
+
result.cached ? (result.stale ? "STALE" : "HIT") : "MISS",
|
|
242
|
+
);
|
|
243
|
+
} else {
|
|
244
|
+
res.setHeader("Cache-Control", PRIVATE_CACHE);
|
|
245
|
+
// Anahtarında cookie olmayan bir cache'in bu yanıtı paylaşmasını
|
|
246
|
+
// engeller; `no-store`'a uymayan bir katman için ikinci savunma.
|
|
247
|
+
res.setHeader("Vary", "Cookie");
|
|
248
|
+
|
|
249
|
+
if (leaked) {
|
|
250
|
+
console.warn(
|
|
251
|
+
`[render] ${req.path} read identity-bound data (${context.taintReasons.join(", ")}), ` +
|
|
252
|
+
`not cached. The route should be registered with 'private: true'.`,
|
|
253
|
+
);
|
|
254
|
+
}
|
|
192
255
|
}
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
);
|
|
197
|
-
await sendHtml(req, res, result.html, result.encoded);
|
|
256
|
+
|
|
257
|
+
// ETag kişiye özel HTML için kullanıcıya özgü bir doğrulayıcıdır ve
|
|
258
|
+
// `no-store` ile birlikte hiçbir işe yaramaz; üretilmesi engellenir.
|
|
259
|
+
await sendHtml(req, res, result.html, result.encoded, { etag: publicCache });
|
|
198
260
|
} catch (error) {
|
|
199
261
|
if (isRedirectError(error)) {
|
|
262
|
+
// Oturuma bağlı bir yönlendirme de kişiye özeldir: "giriş yapmalısın"
|
|
263
|
+
// kararının cache'lenmesi, oturum açmış kullanıcıyı da login sayfasına
|
|
264
|
+
// atan türde hatalara yol açıyor.
|
|
265
|
+
if (isPrivate || context.tainted) {
|
|
266
|
+
res.setHeader("Cache-Control", PRIVATE_CACHE);
|
|
267
|
+
res.setHeader("Vary", "Cookie");
|
|
268
|
+
}
|
|
269
|
+
|
|
200
270
|
res.redirect(error.statusCode, error.location);
|
|
201
271
|
return;
|
|
202
272
|
}
|
|
@@ -205,6 +275,105 @@ export function route(controller, options = {}) {
|
|
|
205
275
|
};
|
|
206
276
|
}
|
|
207
277
|
|
|
278
|
+
/**
|
|
279
|
+
* Layout'suz, asla cache'lenmeyen parça yanıtı.
|
|
280
|
+
*
|
|
281
|
+
* Fragment uçları (tablo sayfası, sekme paneli, canlı tazelenen kart) her
|
|
282
|
+
* projede elle yazılıyor ve `no-store` yazmayı unutmak sessiz bir sızıntıya
|
|
283
|
+
* dönüşüyor. Burada politika sabit: HTML cache'e hiç uğramaz, `no-store` ile
|
|
284
|
+
* ve ETag'siz gider.
|
|
285
|
+
*
|
|
286
|
+
* Hata durumunda tüm sayfa yerine küçük bir hata parçası döner: takas edilen
|
|
287
|
+
* bölge bir hata sayfasının tamamını içine almasın.
|
|
288
|
+
*
|
|
289
|
+
* @param {(ctx: { params: object, query: object, pathname: string,
|
|
290
|
+
* req: import('express').Request }) => Promise<{ view: string, data?: object,
|
|
291
|
+
* status?: number } | string>} controller
|
|
292
|
+
* @returns {import('express').RequestHandler}
|
|
293
|
+
*/
|
|
294
|
+
export function fragment(controller) {
|
|
295
|
+
return async (req, res, next) => {
|
|
296
|
+
const context = createRequestContext({ private: true, res });
|
|
297
|
+
const ctx = {
|
|
298
|
+
params: req.params ?? {},
|
|
299
|
+
query: req.query ?? {},
|
|
300
|
+
pathname: req.path,
|
|
301
|
+
req: guardRequest(req),
|
|
302
|
+
};
|
|
303
|
+
|
|
304
|
+
try {
|
|
305
|
+
const result = await withRequestContext(context, () =>
|
|
306
|
+
withRequestCache(async () => {
|
|
307
|
+
const value = await controller(ctx);
|
|
308
|
+
if (typeof value === "string") return { html: value, status: 200 };
|
|
309
|
+
|
|
310
|
+
return {
|
|
311
|
+
html: await renderView(value.view, value.data ?? {}),
|
|
312
|
+
status: value.status ?? 200,
|
|
313
|
+
};
|
|
314
|
+
}),
|
|
315
|
+
);
|
|
316
|
+
|
|
317
|
+
res.status(result.status);
|
|
318
|
+
res.setHeader("Content-Type", "text/html; charset=utf-8");
|
|
319
|
+
res.setHeader("Cache-Control", PRIVATE_CACHE);
|
|
320
|
+
res.setHeader("Vary", "Cookie");
|
|
321
|
+
await sendHtml(req, res, result.html, undefined, { etag: false });
|
|
322
|
+
} catch (error) {
|
|
323
|
+
if (isRedirectError(error)) {
|
|
324
|
+
res.setHeader("Cache-Control", PRIVATE_CACHE);
|
|
325
|
+
res.redirect(error.statusCode, error.location);
|
|
326
|
+
return;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
if (isNotFoundError(error)) {
|
|
330
|
+
res.status(404).setHeader("Cache-Control", PRIVATE_CACHE);
|
|
331
|
+
res.type("html").send(fragmentError("notFound"));
|
|
332
|
+
return;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
console.error(`[fragment] ${req.method} ${req.originalUrl}`, error);
|
|
336
|
+
|
|
337
|
+
if (res.headersSent) {
|
|
338
|
+
next(error);
|
|
339
|
+
return;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
res.status(500).setHeader("Cache-Control", PRIVATE_CACHE);
|
|
343
|
+
res.type("html").send(fragmentError("failed"));
|
|
344
|
+
}
|
|
345
|
+
};
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* Ziyaretçiye görünen parça hatası metinleri. Framework'ün kendi logları
|
|
350
|
+
* İngilizce ama bu satırlar ekranda okunuyor, bu yüzden durum sayfalarıyla
|
|
351
|
+
* aynı kuralı izliyorlar: dil `brand.lang`.
|
|
352
|
+
*/
|
|
353
|
+
const FRAGMENT_MESSAGES = {
|
|
354
|
+
tr: { notFound: "Bu içerik bulunamadı.", failed: "Bu bölüm yüklenemedi." },
|
|
355
|
+
en: { notFound: "This content was not found.", failed: "This section could not be loaded." },
|
|
356
|
+
};
|
|
357
|
+
|
|
358
|
+
/**
|
|
359
|
+
* Fragment hatası için minimal işaretleme. Şablona bağlı olmaması bilinçli:
|
|
360
|
+
* hata yolu, hatanın kaynağı olabilecek render katmanına geri dönmemeli.
|
|
361
|
+
*
|
|
362
|
+
* @param {"notFound" | "failed"} kind
|
|
363
|
+
* @returns {string}
|
|
364
|
+
*/
|
|
365
|
+
function fragmentError(kind) {
|
|
366
|
+
let lang = "en";
|
|
367
|
+
try {
|
|
368
|
+
lang = getConfig().brand.lang ?? "en";
|
|
369
|
+
} catch {
|
|
370
|
+
/* config yüklenmemişse İngilizce kalır */
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
const table = FRAGMENT_MESSAGES[lang.slice(0, 2).toLowerCase()] ?? FRAGMENT_MESSAGES.en;
|
|
374
|
+
return `<div role="alert" data-fragment-error>${html.esc(table[kind])}</div>`;
|
|
375
|
+
}
|
|
376
|
+
|
|
208
377
|
/**
|
|
209
378
|
* `jskelet.config.mjs` → `cache().html` route'un kendi `revalidate`'ini ezer.
|
|
210
379
|
* Sonuç yol başına hatırlanır: her istekte desen taraması yapılmaz.
|
|
@@ -253,13 +422,23 @@ function resolveRevalidate(pathname, fallback) {
|
|
|
253
422
|
* @param {import('express').Response} res
|
|
254
423
|
* @param {string} body
|
|
255
424
|
* @param {Map<string, Buffer>} [encoded]
|
|
425
|
+
* @param {{ etag?: boolean }} [options] `etag: false` → `res.send()` atlanır,
|
|
426
|
+
* böylece Express kullanıcıya özel gövde için doğrulayıcı üretmez.
|
|
256
427
|
* @returns {Promise<void>}
|
|
257
428
|
*/
|
|
258
|
-
async function sendHtml(req, res, body, encoded) {
|
|
429
|
+
async function sendHtml(req, res, body, encoded, options = {}) {
|
|
259
430
|
const encoding =
|
|
260
431
|
req.method === "HEAD" ? null : negotiateEncoding(req.headers["accept-encoding"]);
|
|
261
432
|
|
|
262
433
|
if (!encoding || !encoded) {
|
|
434
|
+
if (options.etag === false) {
|
|
435
|
+
// Sıkıştırma middleware'i devreye girerse Content-Length'i kendisi
|
|
436
|
+
// kaldırır; girmezse doğru uzunlukla gider.
|
|
437
|
+
res.setHeader("Content-Length", String(Buffer.byteLength(body)));
|
|
438
|
+
res.end(req.method === "HEAD" ? undefined : body);
|
|
439
|
+
return;
|
|
440
|
+
}
|
|
441
|
+
|
|
263
442
|
res.send(body);
|
|
264
443
|
return;
|
|
265
444
|
}
|
|
@@ -279,7 +458,8 @@ async function sendHtml(req, res, body, encoded) {
|
|
|
279
458
|
/**
|
|
280
459
|
* @param {Function} controller
|
|
281
460
|
* @param {{ pathname: string }} ctx
|
|
282
|
-
* @returns {Promise<{ html: string, status: number, degraded?: boolean
|
|
461
|
+
* @returns {Promise<{ html: string, status: number, degraded?: boolean,
|
|
462
|
+
* storable?: boolean }>}
|
|
283
463
|
*/
|
|
284
464
|
async function produce(controller, ctx) {
|
|
285
465
|
try {
|
|
@@ -289,6 +469,9 @@ async function produce(controller, ctx) {
|
|
|
289
469
|
html: rendered,
|
|
290
470
|
status: page.status ?? 200,
|
|
291
471
|
degraded: hasUpstreamFailures(ctx.pathname),
|
|
472
|
+
// Kimliğe bağlı çıktı önbelleğe yazılmaz. Karar burada verilmeli:
|
|
473
|
+
// `withHtmlCache` yazma anında controller'ın ne okuduğunu bilemez.
|
|
474
|
+
storable: getRequestContext()?.tainted !== true,
|
|
292
475
|
};
|
|
293
476
|
} catch (error) {
|
|
294
477
|
if (isNotFoundError(error)) {
|
|
@@ -332,14 +515,14 @@ function hasUpstreamFailures(pathname) {
|
|
|
332
515
|
|
|
333
516
|
if (permanent.length) {
|
|
334
517
|
console.warn(
|
|
335
|
-
`[render] ${pathname}
|
|
518
|
+
`[render] ${pathname} was produced with missing data, upstream is failing permanently (${summarize(permanent)})`,
|
|
336
519
|
);
|
|
337
520
|
}
|
|
338
521
|
|
|
339
522
|
if (!transient.length) return false;
|
|
340
523
|
|
|
341
524
|
console.warn(
|
|
342
|
-
`[render] ${pathname}
|
|
525
|
+
`[render] ${pathname} was produced with missing data, not caching it (${summarize(transient)})`,
|
|
343
526
|
);
|
|
344
527
|
|
|
345
528
|
return true;
|
package/src/server/router.js
CHANGED
|
@@ -14,16 +14,21 @@
|
|
|
14
14
|
*
|
|
15
15
|
* Modül sözleşmesi: default export ya da `register` adlı named export,
|
|
16
16
|
* `(app, api) => void | Promise<void>` imzasıyla. `api` içinde `route`,
|
|
17
|
-
* `renderView`, `renderPage` ve `notFound`/`redirect` hazır gelir,
|
|
18
|
-
* route dosyaları framework'ten tek tek import yapmak zorunda kalmaz.
|
|
17
|
+
* `fragment`, `renderView`, `renderPage` ve `notFound`/`redirect` hazır gelir,
|
|
18
|
+
* böylece route dosyaları framework'ten tek tek import yapmak zorunda kalmaz.
|
|
19
19
|
*/
|
|
20
20
|
import fs from "node:fs";
|
|
21
21
|
import path from "node:path";
|
|
22
22
|
import process from "node:process";
|
|
23
23
|
import { pathToFileURL } from "node:url";
|
|
24
24
|
import { getConfig } from "../config/index.js";
|
|
25
|
-
import { renderPage, renderView, route } from "./render.js";
|
|
26
|
-
import {
|
|
25
|
+
import { fragment, renderPage, renderView, route } from "./render.js";
|
|
26
|
+
import {
|
|
27
|
+
notFound,
|
|
28
|
+
permanentRedirect,
|
|
29
|
+
redirect,
|
|
30
|
+
seeOther,
|
|
31
|
+
} from "../http/control-flow.js";
|
|
27
32
|
|
|
28
33
|
const isDev = process.env.NODE_ENV === "development";
|
|
29
34
|
|
|
@@ -61,11 +66,13 @@ function discover(dir, out = []) {
|
|
|
61
66
|
*/
|
|
62
67
|
const api = {
|
|
63
68
|
route,
|
|
69
|
+
fragment,
|
|
64
70
|
renderView,
|
|
65
71
|
renderPage,
|
|
66
72
|
notFound,
|
|
67
73
|
redirect,
|
|
68
74
|
permanentRedirect,
|
|
75
|
+
seeOther,
|
|
69
76
|
};
|
|
70
77
|
|
|
71
78
|
/**
|
|
@@ -81,7 +88,7 @@ export async function registerRoutes(app) {
|
|
|
81
88
|
|
|
82
89
|
if (!files.length) {
|
|
83
90
|
console.warn(
|
|
84
|
-
`[router]
|
|
91
|
+
`[router] no route modules found — is ${path.relative(config.root, config.dirs.routes)}/ empty?`,
|
|
85
92
|
);
|
|
86
93
|
return 0;
|
|
87
94
|
}
|
|
@@ -99,7 +106,7 @@ export async function registerRoutes(app) {
|
|
|
99
106
|
// et. Üretimde fırlat — yarım route tablosuyla yayına çıkmak,
|
|
100
107
|
// sessizce 404 dönen sayfalar demek.
|
|
101
108
|
if (isDev) {
|
|
102
|
-
console.warn(`[router] ${path.basename(file)}
|
|
109
|
+
console.warn(`[router] ${path.basename(file)} failed to load, skipped`, error);
|
|
103
110
|
continue;
|
|
104
111
|
}
|
|
105
112
|
throw error;
|
|
@@ -108,7 +115,7 @@ export async function registerRoutes(app) {
|
|
|
108
115
|
const register = module.default ?? module.register;
|
|
109
116
|
if (typeof register !== "function") {
|
|
110
117
|
console.warn(
|
|
111
|
-
`[router] ${path.basename(file)} default
|
|
118
|
+
`[router] ${path.basename(file)} exports neither a default nor a 'register' function, skipped`,
|
|
112
119
|
);
|
|
113
120
|
continue;
|
|
114
121
|
}
|
|
@@ -41,7 +41,7 @@ export async function renderStatusPage(status, options = {}) {
|
|
|
41
41
|
const { renderPage } = await import("./render.js");
|
|
42
42
|
return await renderPage({ pathname: `/${status}`, ...page });
|
|
43
43
|
} catch (error) {
|
|
44
|
-
console.error(`[render] ${status}
|
|
44
|
+
console.error(`[render] failed to render the ${status} page`, error);
|
|
45
45
|
return fallbackPage(status);
|
|
46
46
|
}
|
|
47
47
|
}
|
package/src/version.mjs
CHANGED
|
@@ -6,12 +6,17 @@ import fs from "node:fs";
|
|
|
6
6
|
import path from "node:path";
|
|
7
7
|
import { FRAMEWORK_ROOT } from "./config/index.js";
|
|
8
8
|
|
|
9
|
-
|
|
10
|
-
export const FRAMEWORK_VERSION = (() => {
|
|
9
|
+
const manifest = (() => {
|
|
11
10
|
try {
|
|
12
11
|
const file = path.join(FRAMEWORK_ROOT, "package.json");
|
|
13
|
-
return JSON.parse(fs.readFileSync(file, "utf8"))
|
|
12
|
+
return JSON.parse(fs.readFileSync(file, "utf8"));
|
|
14
13
|
} catch {
|
|
15
|
-
return
|
|
14
|
+
return {};
|
|
16
15
|
}
|
|
17
16
|
})();
|
|
17
|
+
|
|
18
|
+
/** @type {string} */
|
|
19
|
+
export const FRAMEWORK_VERSION = manifest.version ?? "0.0.0";
|
|
20
|
+
|
|
21
|
+
/** Paket adı: sürüm kontrolü hangi kayıt defteri girdisine bakacağını buradan bilir. */
|
|
22
|
+
export const FRAMEWORK_PACKAGE = manifest.name ?? "jskelet";
|
|
@@ -72,7 +72,7 @@ export async function loadComponents(dir) {
|
|
|
72
72
|
const previous = origin.get(name);
|
|
73
73
|
if (previous && previous !== BARREL && previous !== relative) {
|
|
74
74
|
console.warn(
|
|
75
|
-
`[components] '${name}'
|
|
75
|
+
`[components] '${name}' is defined twice: ${previous} and ${relative} — the second one wins.`,
|
|
76
76
|
);
|
|
77
77
|
}
|
|
78
78
|
|
|
@@ -4,6 +4,9 @@
|
|
|
4
4
|
*/
|
|
5
5
|
import { attrs, esc, cn } from "./html.js";
|
|
6
6
|
import { asset, getSpriteIds, optimizedImage } from "../../server/assets.js";
|
|
7
|
+
import { getRequestContext, markTainted } from "../../http/request-context.js";
|
|
8
|
+
import { getSignedCookie, randomToken, setSignedCookie } from "../../http/cookies.js";
|
|
9
|
+
import { getConfig } from "../../config/index.js";
|
|
7
10
|
|
|
8
11
|
const isDev = process.env.NODE_ENV === "development";
|
|
9
12
|
|
|
@@ -160,7 +163,7 @@ export function icon(props) {
|
|
|
160
163
|
if (ids.size && !ids.has(id)) {
|
|
161
164
|
warnedIcons.add(id);
|
|
162
165
|
console.warn(
|
|
163
|
-
`[icon] sprite
|
|
166
|
+
`[icon] missing from sprite: ${id} — write the name as a literal or add it to the build/tasks/icons.mjs scan.`,
|
|
164
167
|
);
|
|
165
168
|
}
|
|
166
169
|
}
|
|
@@ -179,6 +182,55 @@ export function icon(props) {
|
|
|
179
182
|
return `<svg${attributes}><use href="${esc(spritePath)}#${esc(id)}"></use></svg>`;
|
|
180
183
|
}
|
|
181
184
|
|
|
185
|
+
/**
|
|
186
|
+
* Formun içine CSRF token'ını gizli alan olarak basar.
|
|
187
|
+
*
|
|
188
|
+
* Token **burada** üretilir ve imzalı cookie olarak yazılır: bir sayfada
|
|
189
|
+
* token gerçekten gerekiyorsa o sayfa zaten kişiye özeldir. Bu yüzden çağrı
|
|
190
|
+
* render'ı işaretler (`tainted`) ve sayfa public HTML cache'ine giremez —
|
|
191
|
+
* aksi hâlde tüm ziyaretçiler cache'ten aynı token'ı alırdı ve çift gönderim
|
|
192
|
+
* kontrolü hiçbir şey doğrulamazdı.
|
|
193
|
+
*
|
|
194
|
+
* `security.csrf.token` kapalıysa boş string döner; şablon her koşulda
|
|
195
|
+
* render edilebilmeli, korumanın açık olması config'in kararı.
|
|
196
|
+
*
|
|
197
|
+
* @returns {string}
|
|
198
|
+
*/
|
|
199
|
+
export function csrfField() {
|
|
200
|
+
const context = getRequestContext();
|
|
201
|
+
if (!context?.res) return "";
|
|
202
|
+
|
|
203
|
+
const { csrf } = getConfig().security;
|
|
204
|
+
if (!csrf.token) return "";
|
|
205
|
+
|
|
206
|
+
if (!context.csrfToken) {
|
|
207
|
+
const existing = getSignedCookie(context.res.req, csrf.cookieName);
|
|
208
|
+
const token = existing ?? randomToken(24);
|
|
209
|
+
|
|
210
|
+
if (!existing) {
|
|
211
|
+
try {
|
|
212
|
+
setSignedCookie(context.res, csrf.cookieName, token, {
|
|
213
|
+
// Token'ı istemci cookie'den değil, basılan gizli alandan okur;
|
|
214
|
+
// HttpOnly kalması XSS durumunda bir katman daha demek.
|
|
215
|
+
httpOnly: true,
|
|
216
|
+
});
|
|
217
|
+
} catch (error) {
|
|
218
|
+
console.warn(
|
|
219
|
+
"[csrf] could not emit the token: no secret for signed cookies",
|
|
220
|
+
error instanceof Error ? error.message : error,
|
|
221
|
+
);
|
|
222
|
+
return "";
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
context.csrfToken = token;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
markTainted("csrfField()");
|
|
230
|
+
|
|
231
|
+
return `<input type="hidden" name="${esc(csrf.fieldName)}" value="${esc(context.csrfToken)}">`;
|
|
232
|
+
}
|
|
233
|
+
|
|
182
234
|
/**
|
|
183
235
|
* `ArrowRightIcon` / `ArrowRight` → `arrow-right`
|
|
184
236
|
* @param {string} name
|