jskelet 0.2.5 → 0.3.0

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 (106) hide show
  1. package/AGENTS.md +132 -132
  2. package/CHANGELOG.md +403 -383
  3. package/LICENSE +21 -21
  4. package/bin/jskelet.mjs +103 -103
  5. package/docs/01-baslangic.md +285 -285
  6. package/docs/02-mimari.md +293 -287
  7. package/docs/03-routing.md +486 -480
  8. package/docs/04-render-ve-sablonlar.md +490 -490
  9. package/docs/05-islands.md +482 -482
  10. package/docs/06-cache.md +1231 -1209
  11. package/docs/07-yapilandirma.md +44 -21
  12. package/docs/08-build.md +366 -366
  13. package/docs/09-dev-araclari.md +335 -335
  14. package/docs/10-dagitim.md +329 -329
  15. package/docs/11-tasima.md +1 -0
  16. package/docs/12-panel-ve-oturum.md +384 -384
  17. package/docs/README.md +105 -105
  18. package/docs/en/01-getting-started.md +292 -292
  19. package/docs/en/02-architecture.md +311 -305
  20. package/docs/en/03-routing.md +503 -497
  21. package/docs/en/04-rendering.md +504 -504
  22. package/docs/en/05-islands.md +492 -492
  23. package/docs/en/06-caching.md +1197 -1198
  24. package/docs/en/07-configuration.md +1009 -986
  25. package/docs/en/08-build.md +383 -383
  26. package/docs/en/09-dev-tools.md +342 -342
  27. package/docs/en/10-deployment.md +332 -332
  28. package/docs/en/11-migration.md +360 -359
  29. package/docs/en/12-dashboards-and-sessions.md +392 -392
  30. package/docs/en/README.md +112 -112
  31. package/package.json +102 -102
  32. package/src/build/ensure-build.mjs +15 -15
  33. package/src/build/paths.mjs +143 -143
  34. package/src/build/resolve-peer.mjs +36 -36
  35. package/src/build/tasks/client.mjs +268 -268
  36. package/src/build/tasks/css.mjs +124 -124
  37. package/src/build/tasks/fonts.mjs +146 -146
  38. package/src/build/tasks/icons.mjs +224 -224
  39. package/src/build/tasks/images.mjs +244 -244
  40. package/src/build/tasks/precompress.mjs +78 -78
  41. package/src/client/{cache-panel → admin}/i18n.js +756 -670
  42. package/src/client/{cache-panel → admin}/login.html +74 -74
  43. package/src/client/{cache-panel → admin}/panel.css +804 -756
  44. package/src/client/admin/panel.html +486 -0
  45. package/src/client/{cache-panel → admin}/panel.js +1242 -915
  46. package/src/client/devtools/report.html +185 -185
  47. package/src/client/devtools/report.js +725 -725
  48. package/src/client/dom.js +95 -95
  49. package/src/client/form.js +192 -192
  50. package/src/client/index.js +35 -35
  51. package/src/client/registry.js +297 -297
  52. package/src/client/safe-image.js +91 -91
  53. package/src/client/store.js +36 -36
  54. package/src/client/swap.js +188 -188
  55. package/src/config/defaults.js +16 -7
  56. package/src/config/index.js +38 -22
  57. package/src/config/pattern.js +107 -107
  58. package/src/http/control-flow.js +71 -71
  59. package/src/http/cookies.js +257 -257
  60. package/src/http/request-cache.js +46 -46
  61. package/src/http/request-context.js +162 -162
  62. package/src/index.js +83 -83
  63. package/src/init.mjs +221 -221
  64. package/src/log.mjs +58 -0
  65. package/src/runtime/alias-hooks.mjs +119 -119
  66. package/src/runtime/register.mjs +4 -4
  67. package/src/server/admin/actions.js +229 -0
  68. package/src/server/admin/auth.js +125 -0
  69. package/src/server/admin/event-log.js +151 -0
  70. package/src/server/admin/gate.js +209 -0
  71. package/src/server/admin/inventory.js +188 -0
  72. package/src/server/admin/mount.js +56 -0
  73. package/src/server/admin/router.js +216 -0
  74. package/src/server/admin/snapshot.js +126 -0
  75. package/src/server/assets.js +147 -147
  76. package/src/server/cache-deps.js +42 -42
  77. package/src/server/cloudflare.js +607 -607
  78. package/src/server/create-app.js +295 -291
  79. package/src/server/data-cache.js +462 -462
  80. package/src/server/dev/report.js +369 -369
  81. package/src/server/dev/socket.js +170 -170
  82. package/src/server/dev/version-check.mjs +139 -139
  83. package/src/server/html-cache.js +817 -817
  84. package/src/server/metadata.js +102 -102
  85. package/src/server/middleware/compression.js +205 -205
  86. package/src/server/middleware/csrf.js +134 -134
  87. package/src/server/middleware/dev-gate.js +62 -62
  88. package/src/server/middleware/headers.js +37 -37
  89. package/src/server/middleware/redirects.js +32 -32
  90. package/src/server/middleware/static-precompressed.js +100 -100
  91. package/src/server/middleware/trailing-slash.js +53 -0
  92. package/src/server/middleware/upstream-proxy.js +141 -141
  93. package/src/server/prewarm.js +601 -601
  94. package/src/server/redis.js +569 -569
  95. package/src/server/router.js +128 -128
  96. package/src/server/status-page.js +164 -164
  97. package/src/server/upstream-limiter.js +376 -376
  98. package/src/server/upstream-tracking.js +166 -166
  99. package/src/start.mjs +7 -7
  100. package/src/templates/layout.ejs +44 -44
  101. package/src/version.mjs +31 -31
  102. package/src/views/components/loader.js +85 -85
  103. package/src/views/helpers/html.js +102 -102
  104. package/src/views/helpers/tags.js +245 -245
  105. package/src/client/cache-panel/panel.html +0 -308
  106. package/src/server/cache-panel.js +0 -759
@@ -1,384 +1,384 @@
1
- # 12 — Panel, oturum ve kişiye özel sayfalar
2
-
3
- JSkelet'in ağırlık merkezi public, önbelleklenebilir sayfalar. Bir panel ise
4
- tam ters eksende durur: her ziyaretçiye farklı HTML, önbellek yok, yoğun
5
- etkileşim. Bu belge o ekseni anlatır — `private: true`, oturum cookie'leri,
6
- CSRF, fragment uçları ve parça takası.
7
-
8
- Kapsam dışında bırakılan bir şey var ve bilinçli: **canlı veri taşıması**.
9
- SSE, WebSocket ya da aralıklı sorgu arasındaki seçim uygulamanın; framework
10
- yalnızca "şu parçayı sunucudan tazele" adımını veriyor. Sıkıştırma katmanı
11
- `text/event-stream` yanıtlarını ve başlıklarını kendisi yazan akışları
12
- atladığı için SSE'yi elle kurmak da bir şeyi bozmuyor.
13
-
14
- Çalışan karşılığı `examples/dashboard/`; bu belgedeki kod parçalarının kaynağı
15
- orası.
16
-
17
- ## Neden ayrı bir yol gerekiyor
18
-
19
- HTML önbelleğinin anahtarı yalnızca yol ve query:
20
-
21
- ```
22
- `${req.path}?${new URLSearchParams(query).toString()}`
23
- ```
24
-
25
- Kimlik anahtarın parçası değil. Yani oturuma bağlı bir sayfa normal `route()`
26
- ile kaydedilirse, ilk isteyenin HTML'i o TTL boyunca **herkese** servis edilir.
27
- Bu hata hiçbir yerde patlamaz: sayfa çalışır, testler geçer, sorun yalnızca
28
- ikinci kullanıcı geldiğinde ve genelde üretimde görünür.
29
-
30
- ## `private: true`
31
-
32
- ```js
33
- export default function register(app, { route, redirect }) {
34
- app.get(
35
- "/panel",
36
- route(
37
- async ({ req }) => {
38
- const user = currentUser(req);
39
- if (!user) redirect("/giris?next=%2Fpanel");
40
-
41
- return { view: "pages/overview", data: { user } };
42
- },
43
- { private: true },
44
- ),
45
- );
46
- }
47
- ```
48
-
49
- Bayrağın yaptığı işler:
50
-
51
- | Davranış | Public `route()` | `private: true` |
52
- | --- | --- | --- |
53
- | HTML önbelleği | TTL varsa açık | Kapalı, açılamaz |
54
- | `cache.html` deseni | TTL'i ezer | Yok sayılır |
55
- | `Cache-Control` | `public, s-maxage=…` | `private, no-store` |
56
- | `Vary` | `Accept-Encoding` | `Cookie, Accept-Encoding` |
57
- | ETag | Var | Yok |
58
- | `X-JSkelet-Cache` | `HIT`/`STALE`/`MISS` | Yazılmaz |
59
-
60
- ETag'in düşmesi ayrıntı gibi görünüyor ama değil: kullanıcıya özel bir gövdenin
61
- güçlü ETag'i, o kullanıcıya özgü bir parmak izidir ve `no-store`'a uymayan bir
62
- katmanda kimlik ayrımı için kullanılabilir.
63
-
64
- Kişiye özel bir sayfadan atılan yönlendirme de önbelleklenmez. "Giriş yapmalısın"
65
- kararı oturuma bağlı; saklanması, giriş yapmış kullanıcının da login sayfasına
66
- atılması demek.
67
-
68
- ## Bayrağı unutursanız
69
-
70
- Framework kimliğe dokunan erişimleri izliyor. Controller'a giden `req`, şu
71
- okumaları işaretleyen ince bir Proxy ile sarılı:
72
-
73
- - `req.headers.cookie`, `req.headers.authorization`,
74
- `req.headers["proxy-authorization"]`
75
- - `req.get("Cookie")` / `req.header("Authorization")`
76
- - `req.cookies`, `req.signedCookies`, `req.session`, `req.user`
77
- - `parseCookies(req)` ve `getSignedCookie(req, …)` (doğrudan bildiriyorlar)
78
-
79
- İşaretlenen bir render önbelleğe **yazılmaz**. Üretimde yanıt `no-store` ile
80
- gider ve şu satır loglanır:
81
-
82
- ```
83
- [render] /panel kimliğe bağlı veri okudu (req.headers.cookie), önbelleğe
84
- alınmadı. Route 'private: true' ile kaydedilmeli.
85
- ```
86
-
87
- Development'ta aynı durum isteği bir hatayla düşürür. Sessiz kalmamasının
88
- sebebi basit: bu hata çalışan bir sayfa üretiyor, yani kendi başına asla fark
89
- edilmiyor.
90
-
91
- `csrfField()` de aynı işaretlemeyi yapıyor. Token basan bir sayfa önbellekten
92
- dönemez — dönerse tüm ziyaretçiler aynı token'ı paylaşır ve çift gönderim
93
- kontrolü hiçbir şey doğrulamaz.
94
-
95
- ## Oturum: imzalı cookie
96
-
97
- Framework kimlik sağlamıyor. Verdiği tek şey "bu değeri ben yazdım,
98
- kurcalanmamış" garantisi:
99
-
100
- ```js
101
- import { clearCookie, getSignedCookie, setSignedCookie } from "jskelet/cookies";
102
-
103
- export function startSession(res, username) {
104
- setSignedCookie(res, "dash_session", username, { maxAge: 60 * 60 * 8 });
105
- }
106
-
107
- export function currentUser(req) {
108
- const username = getSignedCookie(req, "dash_session");
109
- return username ? findUser(username) : null;
110
- }
111
-
112
- export function endSession(res) {
113
- clearCookie(res, "dash_session");
114
- }
115
- ```
116
-
117
- İmza HMAC-SHA256, karşılaştırma sabit zamanlı. İmza uymuyorsa `getSignedCookie`
118
- `null` döner — kurcalanmış bir değer "belki geçerlidir" diye kullanılmaz.
119
-
120
- Sır `security.cookieSecret` ya da `JSKELET_SECRET` ortam değişkeninden gelir.
121
- Sır yoksa imzalı API **hata verir**; "yapılandırma hatası siteyi düşürmez"
122
- kuralı burada geçerli değil, çünkü sessiz alternatif imzasız bir cookie'ye
123
- güvenmek olurdu.
124
-
125
- Varsayılanlar kısıtlayıcı tarafta: `HttpOnly`, `SameSite=Lax`, development
126
- dışında `Secure`, `Path=/`. `SameSite=Lax` tek başına CSRF'in büyük kısmını
127
- kapatıyor — cookie çapraz site POST'larında hiç gönderilmiyor.
128
-
129
- Cookie **şifrelenmiyor**, imzalanıyor. Değer okunabilir; gizli kalması gereken
130
- veriyi değil, onun kimliğini koyun.
131
-
132
- ## CSRF
133
-
134
- Gövdeyi framework ayrıştırıyor (`express.urlencoded` + `express.json`), yani
135
- state değiştiren istekleri kabul eden katman o. Koruma iki katmanlı.
136
-
137
- ### Katman 1 — origin kontrolü (varsayılan açık)
138
-
139
- `Origin` kendi host'umuzla uyuşmuyorsa ya da `Sec-Fetch-Site: cross-site`
140
- geldiyse güvenli olmayan metotlar 403 alır. **Başlıkların hiçbiri yoksa istek
141
- geçer**: tarayıcılar çapraz origin bir POST'ta `Origin`'i her zaman gönderir,
142
- webhook'lar ve sunucudan sunucuya çağrılar hiç göndermez. Bu ayrım korumayı
143
- açık bırakırken entegrasyonları bozmuyor.
144
-
145
- ```js
146
- security: {
147
- csrf: {
148
- // Ayrı bir alan adından gelen panel gibi meşru istisnalar.
149
- allowedOrigins: ["https://admin.example.com"],
150
- // Tarayıcıdan gelmeyen uçlar.
151
- exclude: ["/webhook/:path*"],
152
- },
153
- }
154
- ```
155
-
156
- ### Katman 2 — çift gönderim token'ı (opsiyonel)
157
-
158
- `security.csrf.token: true` ile açılır. Formlar token'ı `csrfField()` ile basar:
159
-
160
- ```ejs
161
- <form method="post" action="/panel/notlar">
162
- <%- csrfField() %>
163
- …
164
- </form>
165
- ```
166
-
167
- Token'ı **middleware üretmiyor**, `csrfField()` üretiyor — yani gerçekten bir
168
- forma basıldığı anda. Sebebi somut: token her yanıtta yazılsaydı public ve
169
- önbelleklenebilir bir sayfa da `Set-Cookie` taşırdı, bir CDN o yanıtı saklardı
170
- ve tüm ziyaretçiler aynı token'ı paylaşırdı.
171
-
172
- Sunucu tarafında token, imzalı cookie ile gönderilen alanın eşleşmesini
173
- istiyor; alan yerine `X-CSRF-Token` başlığı da kabul ediliyor.
174
-
175
- `csrfField()` `security.csrf.token` kapalıyken boş string döner, yani şablon
176
- her koşulda render edilebilir.
177
-
178
- ## Fragment uçları
179
-
180
- `fragment()` politikası sabit bir parça yanıtı üretir: layout basılmaz, yanıt
181
- `private, no-store` ve ETag'siz gider, HTML önbelleğine hiç uğramaz.
182
-
183
- ```js
184
- export default function register(app, { fragment }) {
185
- app.get(
186
- "/_fragment/siparisler",
187
- fragment(async ({ req, query }) => {
188
- const user = currentUser(req);
189
- if (!user) return { view: "partials/session-expired", status: 401 };
190
-
191
- return {
192
- view: "partials/order-table",
193
- data: { siparisler: getOrders(user.username, Number(query.sayfa ?? 1)) },
194
- };
195
- }),
196
- );
197
- }
198
- ```
199
-
200
- Controller ya `{ view, data?, status? }` ya doğrudan bir HTML string döner.
201
- Hata durumunda tüm sayfa yerine küçük bir parça döner
202
- (`<div role="alert" data-fragment-error>`): takas edilen bölge bir hata
203
- sayfasının tamamını içine almamalı.
204
-
205
- Aynı şablonun sayfanın içinde de fragment ucunda da kullanılması esas fikir —
206
- işaretlemenin tek kaynağı sunucuda kalır, istemci ikinci bir şablon taşımaz.
207
-
208
- ## İstemci: parça takası
209
-
210
- ```js
211
- import { registerAll, start, startForms, startSwapLinks } from "jskelet/client";
212
-
213
- registerAll({ "live-clock": () => import("../islands/live-clock.js") });
214
-
215
- start();
216
- startSwapLinks();
217
- startForms();
218
- ```
219
-
220
- `startSwapLinks()` `data-swap` taşıyan bağlantıları bağlar:
221
-
222
- ```html
223
- <a href="/_fragment/siparisler?sayfa=2" data-swap="#siparisler">Sonraki</a>
224
- ```
225
-
226
- `href` gerçek bir URL olduğu için JS yoksa bağlantı normal gezinmeye düşer.
227
- Programatik kullanım için `swap()`:
228
-
229
- ```js
230
- import { swap } from "jskelet/client";
231
-
232
- await swap("#siparisler", "/_fragment/siparisler?sayfa=2", { history: true });
233
- ```
234
-
235
- `swap()` sırayla: eski alt ağacın island'larını söker, içeriği değiştirir, yeni
236
- alt ağacı hidre eder ve odağı kaybolmuşsa geri getirir. İstek süresince bölgeye
237
- `aria-busy="true"` yazılır — bekleme göstergesini ayrı bir sınıfa bağlamak
238
- yerine erişilebilirlik durumuna bağlamak ikisinin birbirinden ayrı düşmesini de
239
- engelliyor.
240
-
241
- Yönlendirmeyle karşılaşırsa (oturum düştü, login'e gidiliyor) parçayı takmak
242
- yerine sayfayı o adrese götürür.
243
-
244
- ### Island sökme
245
-
246
- Bu, takasın en kolay atlanan yarısı. `mount()` bir temizlik fonksiyonu
247
- döndürebiliyor:
248
-
249
- ```js
250
- export function mount(element) {
251
- const timer = setInterval(() => tick(element), 1000);
252
- return () => clearInterval(timer);
253
- }
254
- ```
255
-
256
- `innerHTML` ile değiştirilen bir bölgenin island'ları DOM'dan çıkar ama
257
- `document`/`window` üzerine kurdukları dinleyiciler ve `setInterval`'ları
258
- yaşamaya devam eder; birkaç takastan sonra aynı iş onlarca kez çalışır.
259
- `swap()` ve form yardımcıları `unmount()` çağırıyor, elle DOM değiştiriyorsanız
260
- sizin çağırmanız gerekiyor:
261
-
262
- ```js
263
- import { hydrate, unmount } from "jskelet/client";
264
-
265
- unmount(bolge);
266
- bolge.innerHTML = html;
267
- hydrate(bolge);
268
- ```
269
-
270
- ## Formlar
271
-
272
- `startForms()` `data-enhance` taşıyan formları bağlar. Sözleşme progressive
273
- enhancement: form normal bir `<form method="post" action="…">`, JS yalnızca
274
- aradaki tam sayfa turunu kaldırıyor.
275
-
276
- ```html
277
- <form method="post" action="/panel/notlar" data-enhance data-target="#notlar">
278
- <%- csrfField() %>
279
- <textarea name="metin" required minlength="3"></textarea>
280
- <button type="submit">Kaydet</button>
281
- </form>
282
- ```
283
-
284
- Sunucu üç cevaptan birini verir:
285
-
286
- - **yönlendirme** → `location.assign` ile izlenir (başarılı mutasyon, JS'siz yol)
287
- - **4xx + parça** → formun yerine takılır (doğrulama hataları)
288
- - **2xx + parça** → `data-target` bölgesine takılır ve form sıfırlanır
289
-
290
- Sunucu tarafı ikisini de karşılıyor:
291
-
292
- ```js
293
- app.post(
294
- "/panel/notlar",
295
- fragment(async ({ req }) => {
296
- const user = currentUser(req);
297
- if (!user) seeOther("/giris?next=%2Fpanel");
298
-
299
- const result = addNote(user.username, req.body?.metin);
300
-
301
- // JS kapalıysa istemci `X-Requested-With` göndermez: tam sayfa turu.
302
- if (req.get("X-Requested-With") !== "fragment") {
303
- seeOther(result.ok ? "/panel" : "/panel?not=hata");
304
- }
305
-
306
- return result.ok
307
- ? { view: "partials/note-list", data: { notlar: getNotes(user.username) } }
308
- : { view: "partials/note-form", data: { hata: result.error }, status: 422 };
309
- }),
310
- );
311
- ```
312
-
313
- `seeOther()` yerine `redirect()` kullanmak burada bir hata olurdu: `redirect()`
314
- 307 yazıyor ve 307 metodu koruyor, yani tarayıcı hedefe yeniden POST ediyor.
315
- Form sonrası akış 303 gerektiriyor.
316
-
317
- POST handler'ının `fragment()` içinden geçmesinin sebebi de var: layout'suz
318
- render ve `no-store`'un yanında **istek bağlamı** kazandırıyor. Bağlam olmadan
319
- hata durumunda yeniden basılan formun `csrfField()`i boş çıkar ve kullanıcının
320
- ikinci denemesi 403 alır.
321
-
322
- Doğrulama hatasında odak ilk hatalı alana taşınıyor; işaret olarak
323
- `aria-invalid="true"` ya da `data-field-error` aranıyor.
324
-
325
- ## Yapılandırma
326
-
327
- ```js
328
- export default {
329
- security: {
330
- /**
331
- * Ters proxy arkasında değilsen kapat: açıkken istemci kendi
332
- * `X-Forwarded-For` başlığını uydurabilir.
333
- */
334
- trustProxy: true,
335
-
336
- cookieSecret: process.env.JSKELET_SECRET,
337
-
338
- csrf: {
339
- enabled: true,
340
- token: false,
341
- allowedOrigins: [],
342
- exclude: [],
343
- cookieName: "csrf_token",
344
- fieldName: "_csrf",
345
- headerName: "x-csrf-token",
346
- },
347
- },
348
- };
349
- ```
350
-
351
- Panel yolları için iki ek ayar işe yarıyor:
352
-
353
- ```js
354
- // Yan etkili bir bağlantının önden getirilmesi kullanıcıyı oturumdan atabilir.
355
- navigation: { exclude: ["/panel/:path*", "/cikis"] },
356
-
357
- // Isıtıcının oturumu yok; korumalı sayfalar ısıtılamaz.
358
- prewarmSkip: ["/api/", "/_fragment/", "/panel", "/cikis"],
359
- ```
360
-
361
- Ve `headers()` ile indeksleme kapatılır — `no-store` önbelleği engelliyor, ama
362
- indekslemeyi ayrıca söylemek gerekiyor:
363
-
364
- ```js
365
- {
366
- source: "/panel/:path*",
367
- headers: [{ key: "X-Robots-Tag", value: "noindex, nofollow" }],
368
- }
369
- ```
370
-
371
- ## Kontrol listesi
372
-
373
- Kişiye özel bir bölüm eklerken:
374
-
375
- - [ ] Sayfalar `route(fn, { private: true })` ile kayıtlı.
376
- - [ ] Fragment uçları `fragment()` ile kayıtlı.
377
- - [ ] `security.cookieSecret` ortam değişkeninden geliyor, kodda sabit değil.
378
- - [ ] Mutasyon formlarında `csrfField()` var; `security.csrf.token` açık.
379
- - [ ] Çıkış bir POST; GET değil.
380
- - [ ] Girişten sonraki `next` parametresi yalnızca site içi yolları kabul ediyor.
381
- - [ ] `prewarmSkip` ve `navigation.exclude` korumalı öneki dışlıyor.
382
- - [ ] `headers()` altında `X-Robots-Tag: noindex`.
383
- - [ ] Duman testi `no-store` başlığını ve token'sız POST'un reddini kontrol
384
- ediyor — bunlar bozulduğunda ekranda hiçbir şey değişmiyor.
1
+ # 12 — Panel, oturum ve kişiye özel sayfalar
2
+
3
+ JSkelet'in ağırlık merkezi public, önbelleklenebilir sayfalar. Bir panel ise
4
+ tam ters eksende durur: her ziyaretçiye farklı HTML, önbellek yok, yoğun
5
+ etkileşim. Bu belge o ekseni anlatır — `private: true`, oturum cookie'leri,
6
+ CSRF, fragment uçları ve parça takası.
7
+
8
+ Kapsam dışında bırakılan bir şey var ve bilinçli: **canlı veri taşıması**.
9
+ SSE, WebSocket ya da aralıklı sorgu arasındaki seçim uygulamanın; framework
10
+ yalnızca "şu parçayı sunucudan tazele" adımını veriyor. Sıkıştırma katmanı
11
+ `text/event-stream` yanıtlarını ve başlıklarını kendisi yazan akışları
12
+ atladığı için SSE'yi elle kurmak da bir şeyi bozmuyor.
13
+
14
+ Çalışan karşılığı `examples/dashboard/`; bu belgedeki kod parçalarının kaynağı
15
+ orası.
16
+
17
+ ## Neden ayrı bir yol gerekiyor
18
+
19
+ HTML önbelleğinin anahtarı yalnızca yol ve query:
20
+
21
+ ```
22
+ `${req.path}?${new URLSearchParams(query).toString()}`
23
+ ```
24
+
25
+ Kimlik anahtarın parçası değil. Yani oturuma bağlı bir sayfa normal `route()`
26
+ ile kaydedilirse, ilk isteyenin HTML'i o TTL boyunca **herkese** servis edilir.
27
+ Bu hata hiçbir yerde patlamaz: sayfa çalışır, testler geçer, sorun yalnızca
28
+ ikinci kullanıcı geldiğinde ve genelde üretimde görünür.
29
+
30
+ ## `private: true`
31
+
32
+ ```js
33
+ export default function register(app, { route, redirect }) {
34
+ app.get(
35
+ "/panel",
36
+ route(
37
+ async ({ req }) => {
38
+ const user = currentUser(req);
39
+ if (!user) redirect("/giris?next=%2Fpanel");
40
+
41
+ return { view: "pages/overview", data: { user } };
42
+ },
43
+ { private: true },
44
+ ),
45
+ );
46
+ }
47
+ ```
48
+
49
+ Bayrağın yaptığı işler:
50
+
51
+ | Davranış | Public `route()` | `private: true` |
52
+ | --- | --- | --- |
53
+ | HTML önbelleği | TTL varsa açık | Kapalı, açılamaz |
54
+ | `cache.html` deseni | TTL'i ezer | Yok sayılır |
55
+ | `Cache-Control` | `public, s-maxage=…` | `private, no-store` |
56
+ | `Vary` | `Accept-Encoding` | `Cookie, Accept-Encoding` |
57
+ | ETag | Var | Yok |
58
+ | `X-JSkelet-Cache` | `HIT`/`STALE`/`MISS` | Yazılmaz |
59
+
60
+ ETag'in düşmesi ayrıntı gibi görünüyor ama değil: kullanıcıya özel bir gövdenin
61
+ güçlü ETag'i, o kullanıcıya özgü bir parmak izidir ve `no-store`'a uymayan bir
62
+ katmanda kimlik ayrımı için kullanılabilir.
63
+
64
+ Kişiye özel bir sayfadan atılan yönlendirme de önbelleklenmez. "Giriş yapmalısın"
65
+ kararı oturuma bağlı; saklanması, giriş yapmış kullanıcının da login sayfasına
66
+ atılması demek.
67
+
68
+ ## Bayrağı unutursanız
69
+
70
+ Framework kimliğe dokunan erişimleri izliyor. Controller'a giden `req`, şu
71
+ okumaları işaretleyen ince bir Proxy ile sarılı:
72
+
73
+ - `req.headers.cookie`, `req.headers.authorization`,
74
+ `req.headers["proxy-authorization"]`
75
+ - `req.get("Cookie")` / `req.header("Authorization")`
76
+ - `req.cookies`, `req.signedCookies`, `req.session`, `req.user`
77
+ - `parseCookies(req)` ve `getSignedCookie(req, …)` (doğrudan bildiriyorlar)
78
+
79
+ İşaretlenen bir render önbelleğe **yazılmaz**. Üretimde yanıt `no-store` ile
80
+ gider ve şu satır loglanır:
81
+
82
+ ```
83
+ [render] /panel kimliğe bağlı veri okudu (req.headers.cookie), önbelleğe
84
+ alınmadı. Route 'private: true' ile kaydedilmeli.
85
+ ```
86
+
87
+ Development'ta aynı durum isteği bir hatayla düşürür. Sessiz kalmamasının
88
+ sebebi basit: bu hata çalışan bir sayfa üretiyor, yani kendi başına asla fark
89
+ edilmiyor.
90
+
91
+ `csrfField()` de aynı işaretlemeyi yapıyor. Token basan bir sayfa önbellekten
92
+ dönemez — dönerse tüm ziyaretçiler aynı token'ı paylaşır ve çift gönderim
93
+ kontrolü hiçbir şey doğrulamaz.
94
+
95
+ ## Oturum: imzalı cookie
96
+
97
+ Framework kimlik sağlamıyor. Verdiği tek şey "bu değeri ben yazdım,
98
+ kurcalanmamış" garantisi:
99
+
100
+ ```js
101
+ import { clearCookie, getSignedCookie, setSignedCookie } from "jskelet/cookies";
102
+
103
+ export function startSession(res, username) {
104
+ setSignedCookie(res, "dash_session", username, { maxAge: 60 * 60 * 8 });
105
+ }
106
+
107
+ export function currentUser(req) {
108
+ const username = getSignedCookie(req, "dash_session");
109
+ return username ? findUser(username) : null;
110
+ }
111
+
112
+ export function endSession(res) {
113
+ clearCookie(res, "dash_session");
114
+ }
115
+ ```
116
+
117
+ İmza HMAC-SHA256, karşılaştırma sabit zamanlı. İmza uymuyorsa `getSignedCookie`
118
+ `null` döner — kurcalanmış bir değer "belki geçerlidir" diye kullanılmaz.
119
+
120
+ Sır `security.cookieSecret` ya da `JSKELET_SECRET` ortam değişkeninden gelir.
121
+ Sır yoksa imzalı API **hata verir**; "yapılandırma hatası siteyi düşürmez"
122
+ kuralı burada geçerli değil, çünkü sessiz alternatif imzasız bir cookie'ye
123
+ güvenmek olurdu.
124
+
125
+ Varsayılanlar kısıtlayıcı tarafta: `HttpOnly`, `SameSite=Lax`, development
126
+ dışında `Secure`, `Path=/`. `SameSite=Lax` tek başına CSRF'in büyük kısmını
127
+ kapatıyor — cookie çapraz site POST'larında hiç gönderilmiyor.
128
+
129
+ Cookie **şifrelenmiyor**, imzalanıyor. Değer okunabilir; gizli kalması gereken
130
+ veriyi değil, onun kimliğini koyun.
131
+
132
+ ## CSRF
133
+
134
+ Gövdeyi framework ayrıştırıyor (`express.urlencoded` + `express.json`), yani
135
+ state değiştiren istekleri kabul eden katman o. Koruma iki katmanlı.
136
+
137
+ ### Katman 1 — origin kontrolü (varsayılan açık)
138
+
139
+ `Origin` kendi host'umuzla uyuşmuyorsa ya da `Sec-Fetch-Site: cross-site`
140
+ geldiyse güvenli olmayan metotlar 403 alır. **Başlıkların hiçbiri yoksa istek
141
+ geçer**: tarayıcılar çapraz origin bir POST'ta `Origin`'i her zaman gönderir,
142
+ webhook'lar ve sunucudan sunucuya çağrılar hiç göndermez. Bu ayrım korumayı
143
+ açık bırakırken entegrasyonları bozmuyor.
144
+
145
+ ```js
146
+ security: {
147
+ csrf: {
148
+ // Ayrı bir alan adından gelen panel gibi meşru istisnalar.
149
+ allowedOrigins: ["https://admin.example.com"],
150
+ // Tarayıcıdan gelmeyen uçlar.
151
+ exclude: ["/webhook/:path*"],
152
+ },
153
+ }
154
+ ```
155
+
156
+ ### Katman 2 — çift gönderim token'ı (opsiyonel)
157
+
158
+ `security.csrf.token: true` ile açılır. Formlar token'ı `csrfField()` ile basar:
159
+
160
+ ```ejs
161
+ <form method="post" action="/panel/notlar">
162
+ <%- csrfField() %>
163
+ …
164
+ </form>
165
+ ```
166
+
167
+ Token'ı **middleware üretmiyor**, `csrfField()` üretiyor — yani gerçekten bir
168
+ forma basıldığı anda. Sebebi somut: token her yanıtta yazılsaydı public ve
169
+ önbelleklenebilir bir sayfa da `Set-Cookie` taşırdı, bir CDN o yanıtı saklardı
170
+ ve tüm ziyaretçiler aynı token'ı paylaşırdı.
171
+
172
+ Sunucu tarafında token, imzalı cookie ile gönderilen alanın eşleşmesini
173
+ istiyor; alan yerine `X-CSRF-Token` başlığı da kabul ediliyor.
174
+
175
+ `csrfField()` `security.csrf.token` kapalıyken boş string döner, yani şablon
176
+ her koşulda render edilebilir.
177
+
178
+ ## Fragment uçları
179
+
180
+ `fragment()` politikası sabit bir parça yanıtı üretir: layout basılmaz, yanıt
181
+ `private, no-store` ve ETag'siz gider, HTML önbelleğine hiç uğramaz.
182
+
183
+ ```js
184
+ export default function register(app, { fragment }) {
185
+ app.get(
186
+ "/_fragment/siparisler",
187
+ fragment(async ({ req, query }) => {
188
+ const user = currentUser(req);
189
+ if (!user) return { view: "partials/session-expired", status: 401 };
190
+
191
+ return {
192
+ view: "partials/order-table",
193
+ data: { siparisler: getOrders(user.username, Number(query.sayfa ?? 1)) },
194
+ };
195
+ }),
196
+ );
197
+ }
198
+ ```
199
+
200
+ Controller ya `{ view, data?, status? }` ya doğrudan bir HTML string döner.
201
+ Hata durumunda tüm sayfa yerine küçük bir parça döner
202
+ (`<div role="alert" data-fragment-error>`): takas edilen bölge bir hata
203
+ sayfasının tamamını içine almamalı.
204
+
205
+ Aynı şablonun sayfanın içinde de fragment ucunda da kullanılması esas fikir —
206
+ işaretlemenin tek kaynağı sunucuda kalır, istemci ikinci bir şablon taşımaz.
207
+
208
+ ## İstemci: parça takası
209
+
210
+ ```js
211
+ import { registerAll, start, startForms, startSwapLinks } from "jskelet/client";
212
+
213
+ registerAll({ "live-clock": () => import("../islands/live-clock.js") });
214
+
215
+ start();
216
+ startSwapLinks();
217
+ startForms();
218
+ ```
219
+
220
+ `startSwapLinks()` `data-swap` taşıyan bağlantıları bağlar:
221
+
222
+ ```html
223
+ <a href="/_fragment/siparisler?sayfa=2" data-swap="#siparisler">Sonraki</a>
224
+ ```
225
+
226
+ `href` gerçek bir URL olduğu için JS yoksa bağlantı normal gezinmeye düşer.
227
+ Programatik kullanım için `swap()`:
228
+
229
+ ```js
230
+ import { swap } from "jskelet/client";
231
+
232
+ await swap("#siparisler", "/_fragment/siparisler?sayfa=2", { history: true });
233
+ ```
234
+
235
+ `swap()` sırayla: eski alt ağacın island'larını söker, içeriği değiştirir, yeni
236
+ alt ağacı hidre eder ve odağı kaybolmuşsa geri getirir. İstek süresince bölgeye
237
+ `aria-busy="true"` yazılır — bekleme göstergesini ayrı bir sınıfa bağlamak
238
+ yerine erişilebilirlik durumuna bağlamak ikisinin birbirinden ayrı düşmesini de
239
+ engelliyor.
240
+
241
+ Yönlendirmeyle karşılaşırsa (oturum düştü, login'e gidiliyor) parçayı takmak
242
+ yerine sayfayı o adrese götürür.
243
+
244
+ ### Island sökme
245
+
246
+ Bu, takasın en kolay atlanan yarısı. `mount()` bir temizlik fonksiyonu
247
+ döndürebiliyor:
248
+
249
+ ```js
250
+ export function mount(element) {
251
+ const timer = setInterval(() => tick(element), 1000);
252
+ return () => clearInterval(timer);
253
+ }
254
+ ```
255
+
256
+ `innerHTML` ile değiştirilen bir bölgenin island'ları DOM'dan çıkar ama
257
+ `document`/`window` üzerine kurdukları dinleyiciler ve `setInterval`'ları
258
+ yaşamaya devam eder; birkaç takastan sonra aynı iş onlarca kez çalışır.
259
+ `swap()` ve form yardımcıları `unmount()` çağırıyor, elle DOM değiştiriyorsanız
260
+ sizin çağırmanız gerekiyor:
261
+
262
+ ```js
263
+ import { hydrate, unmount } from "jskelet/client";
264
+
265
+ unmount(bolge);
266
+ bolge.innerHTML = html;
267
+ hydrate(bolge);
268
+ ```
269
+
270
+ ## Formlar
271
+
272
+ `startForms()` `data-enhance` taşıyan formları bağlar. Sözleşme progressive
273
+ enhancement: form normal bir `<form method="post" action="…">`, JS yalnızca
274
+ aradaki tam sayfa turunu kaldırıyor.
275
+
276
+ ```html
277
+ <form method="post" action="/panel/notlar" data-enhance data-target="#notlar">
278
+ <%- csrfField() %>
279
+ <textarea name="metin" required minlength="3"></textarea>
280
+ <button type="submit">Kaydet</button>
281
+ </form>
282
+ ```
283
+
284
+ Sunucu üç cevaptan birini verir:
285
+
286
+ - **yönlendirme** → `location.assign` ile izlenir (başarılı mutasyon, JS'siz yol)
287
+ - **4xx + parça** → formun yerine takılır (doğrulama hataları)
288
+ - **2xx + parça** → `data-target` bölgesine takılır ve form sıfırlanır
289
+
290
+ Sunucu tarafı ikisini de karşılıyor:
291
+
292
+ ```js
293
+ app.post(
294
+ "/panel/notlar",
295
+ fragment(async ({ req }) => {
296
+ const user = currentUser(req);
297
+ if (!user) seeOther("/giris?next=%2Fpanel");
298
+
299
+ const result = addNote(user.username, req.body?.metin);
300
+
301
+ // JS kapalıysa istemci `X-Requested-With` göndermez: tam sayfa turu.
302
+ if (req.get("X-Requested-With") !== "fragment") {
303
+ seeOther(result.ok ? "/panel" : "/panel?not=hata");
304
+ }
305
+
306
+ return result.ok
307
+ ? { view: "partials/note-list", data: { notlar: getNotes(user.username) } }
308
+ : { view: "partials/note-form", data: { hata: result.error }, status: 422 };
309
+ }),
310
+ );
311
+ ```
312
+
313
+ `seeOther()` yerine `redirect()` kullanmak burada bir hata olurdu: `redirect()`
314
+ 307 yazıyor ve 307 metodu koruyor, yani tarayıcı hedefe yeniden POST ediyor.
315
+ Form sonrası akış 303 gerektiriyor.
316
+
317
+ POST handler'ının `fragment()` içinden geçmesinin sebebi de var: layout'suz
318
+ render ve `no-store`'un yanında **istek bağlamı** kazandırıyor. Bağlam olmadan
319
+ hata durumunda yeniden basılan formun `csrfField()`i boş çıkar ve kullanıcının
320
+ ikinci denemesi 403 alır.
321
+
322
+ Doğrulama hatasında odak ilk hatalı alana taşınıyor; işaret olarak
323
+ `aria-invalid="true"` ya da `data-field-error` aranıyor.
324
+
325
+ ## Yapılandırma
326
+
327
+ ```js
328
+ export default {
329
+ security: {
330
+ /**
331
+ * Ters proxy arkasında değilsen kapat: açıkken istemci kendi
332
+ * `X-Forwarded-For` başlığını uydurabilir.
333
+ */
334
+ trustProxy: true,
335
+
336
+ cookieSecret: process.env.JSKELET_SECRET,
337
+
338
+ csrf: {
339
+ enabled: true,
340
+ token: false,
341
+ allowedOrigins: [],
342
+ exclude: [],
343
+ cookieName: "csrf_token",
344
+ fieldName: "_csrf",
345
+ headerName: "x-csrf-token",
346
+ },
347
+ },
348
+ };
349
+ ```
350
+
351
+ Panel yolları için iki ek ayar işe yarıyor:
352
+
353
+ ```js
354
+ // Yan etkili bir bağlantının önden getirilmesi kullanıcıyı oturumdan atabilir.
355
+ navigation: { exclude: ["/panel/:path*", "/cikis"] },
356
+
357
+ // Isıtıcının oturumu yok; korumalı sayfalar ısıtılamaz.
358
+ prewarmSkip: ["/api/", "/_fragment/", "/panel", "/cikis"],
359
+ ```
360
+
361
+ Ve `headers()` ile indeksleme kapatılır — `no-store` önbelleği engelliyor, ama
362
+ indekslemeyi ayrıca söylemek gerekiyor:
363
+
364
+ ```js
365
+ {
366
+ source: "/panel/:path*",
367
+ headers: [{ key: "X-Robots-Tag", value: "noindex, nofollow" }],
368
+ }
369
+ ```
370
+
371
+ ## Kontrol listesi
372
+
373
+ Kişiye özel bir bölüm eklerken:
374
+
375
+ - [ ] Sayfalar `route(fn, { private: true })` ile kayıtlı.
376
+ - [ ] Fragment uçları `fragment()` ile kayıtlı.
377
+ - [ ] `security.cookieSecret` ortam değişkeninden geliyor, kodda sabit değil.
378
+ - [ ] Mutasyon formlarında `csrfField()` var; `security.csrf.token` açık.
379
+ - [ ] Çıkış bir POST; GET değil.
380
+ - [ ] Girişten sonraki `next` parametresi yalnızca site içi yolları kabul ediyor.
381
+ - [ ] `prewarmSkip` ve `navigation.exclude` korumalı öneki dışlıyor.
382
+ - [ ] `headers()` altında `X-Robots-Tag: noindex`.
383
+ - [ ] Duman testi `no-store` başlığını ve token'sız POST'un reddini kontrol
384
+ ediyor — bunlar bozulduğunda ekranda hiçbir şey değişmiyor.