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,192 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Form gönderimini sayfa yenilemeden yürütür — ama yenilemeyi de bozmadan.
|
|
3
|
+
*
|
|
4
|
+
* Sözleşme bilinçli olarak "progressive enhancement": form normal bir
|
|
5
|
+
* `<form method="post" action="…">`. JS yüklenmemişse, hata verdiyse ya da
|
|
6
|
+
* kullanıcı kapattıysa tarayıcı formu kendisi gönderir ve sunucu yönlendirme
|
|
7
|
+
* ile cevap verir. Bu dosya yalnızca aradaki tam sayfa turunu kaldırıyor.
|
|
8
|
+
*
|
|
9
|
+
* Sunucudan üç cevaptan biri beklenir:
|
|
10
|
+
* - yönlendirme → `location.assign` ile izlenir (başarılı mutasyon)
|
|
11
|
+
* - 4xx + parça → formun yerine takılır (alan bazlı doğrulama hataları)
|
|
12
|
+
* - 2xx + parça → hedef bölgeye takılır (güncellenen liste, kart)
|
|
13
|
+
*
|
|
14
|
+
* CSRF token'ı `csrfField()` tarafından basılan gizli alanda; `FormData`
|
|
15
|
+
* onu kendiliğinden taşıdığı için burada ek bir iş yok.
|
|
16
|
+
*/
|
|
17
|
+
import { hydrate, unmount } from "./registry.js";
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* @param {HTMLFormElement} form
|
|
21
|
+
* @param {boolean} busy
|
|
22
|
+
*/
|
|
23
|
+
function setBusy(form, busy) {
|
|
24
|
+
form.toggleAttribute("data-submitting", busy);
|
|
25
|
+
|
|
26
|
+
for (const element of form.elements) {
|
|
27
|
+
if (
|
|
28
|
+
element instanceof HTMLButtonElement ||
|
|
29
|
+
(element instanceof HTMLInputElement && element.type === "submit")
|
|
30
|
+
) {
|
|
31
|
+
element.disabled = busy;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* @param {HTMLElement} element
|
|
38
|
+
* @param {string} html
|
|
39
|
+
*/
|
|
40
|
+
function replaceContent(element, html) {
|
|
41
|
+
unmount(element);
|
|
42
|
+
element.innerHTML = html;
|
|
43
|
+
hydrate(element);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* @param {HTMLFormElement} form
|
|
48
|
+
* @param {string} html
|
|
49
|
+
*/
|
|
50
|
+
function replaceForm(form, html) {
|
|
51
|
+
const holder = document.createElement("div");
|
|
52
|
+
holder.innerHTML = html;
|
|
53
|
+
|
|
54
|
+
const replacement = holder.firstElementChild;
|
|
55
|
+
if (!replacement) return;
|
|
56
|
+
|
|
57
|
+
unmount(form);
|
|
58
|
+
form.replaceWith(replacement);
|
|
59
|
+
hydrate(replacement);
|
|
60
|
+
|
|
61
|
+
// Doğrulama hatasında odak ilk hatalı alana gitmeli; klavye kullanıcısı
|
|
62
|
+
// aksi hâlde hatanın nerede olduğunu bulmak için baştan geziyor.
|
|
63
|
+
const invalid = replacement.querySelector("[aria-invalid='true'], [data-field-error]");
|
|
64
|
+
if (invalid instanceof HTMLElement) invalid.focus();
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* @param {HTMLFormElement} form
|
|
69
|
+
* @param {HTMLElement | null} submitter
|
|
70
|
+
*/
|
|
71
|
+
async function submitForm(form, submitter) {
|
|
72
|
+
setBusy(form, true);
|
|
73
|
+
|
|
74
|
+
const method = (form.method || "post").toUpperCase();
|
|
75
|
+
const action = form.action || location.href;
|
|
76
|
+
const data = new FormData(form);
|
|
77
|
+
|
|
78
|
+
// Gönderen butonun `name`/`value`'su `FormData`'ya girmez; hangi eylemin
|
|
79
|
+
// tetiklendiğini ayırt eden formlar (kaydet / sil) buna güveniyor.
|
|
80
|
+
if (
|
|
81
|
+
(submitter instanceof HTMLButtonElement ||
|
|
82
|
+
submitter instanceof HTMLInputElement) &&
|
|
83
|
+
submitter.name
|
|
84
|
+
) {
|
|
85
|
+
data.append(submitter.name, submitter.value);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
try {
|
|
89
|
+
const isGet = method === "GET";
|
|
90
|
+
const query = new URLSearchParams();
|
|
91
|
+
if (isGet) {
|
|
92
|
+
for (const [key, value] of data.entries()) {
|
|
93
|
+
if (typeof value === "string") query.append(key, value);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const response = await fetch(isGet ? `${action.split("?")[0]}?${query}` : action, {
|
|
98
|
+
method,
|
|
99
|
+
body: isGet ? null : data,
|
|
100
|
+
headers: { "X-Requested-With": "fragment" },
|
|
101
|
+
credentials: "same-origin",
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
if (response.redirected) {
|
|
105
|
+
location.assign(response.url);
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const html = await response.text();
|
|
110
|
+
const target = form.dataset.target
|
|
111
|
+
? document.querySelector(form.dataset.target)
|
|
112
|
+
: null;
|
|
113
|
+
|
|
114
|
+
if (!response.ok || !target) {
|
|
115
|
+
replaceForm(form, html);
|
|
116
|
+
} else {
|
|
117
|
+
replaceContent(/** @type {HTMLElement} */ (target), html);
|
|
118
|
+
form.reset();
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
form.dispatchEvent(
|
|
122
|
+
new CustomEvent("jskelet:submitted", {
|
|
123
|
+
bubbles: true,
|
|
124
|
+
detail: { ok: response.ok, status: response.status },
|
|
125
|
+
}),
|
|
126
|
+
);
|
|
127
|
+
} catch (error) {
|
|
128
|
+
console.error("[form] submit failed", error);
|
|
129
|
+
// Ağ hatasında formu kilitli bırakmıyoruz: kullanıcı tekrar denesin.
|
|
130
|
+
form.dispatchEvent(
|
|
131
|
+
new CustomEvent("jskelet:submit-error", { bubbles: true, detail: { error } }),
|
|
132
|
+
);
|
|
133
|
+
} finally {
|
|
134
|
+
setBusy(form, false);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* @param {SubmitEvent} event
|
|
140
|
+
* @param {HTMLFormElement} form
|
|
141
|
+
*/
|
|
142
|
+
function onSubmit(event, form) {
|
|
143
|
+
// Tarayıcının kendi doğrulaması geçmediyse hiç ağa çıkmıyoruz; hata
|
|
144
|
+
// balonlarını da tarayıcı gösterir.
|
|
145
|
+
if (!form.checkValidity()) return;
|
|
146
|
+
if (form.hasAttribute("data-submitting")) {
|
|
147
|
+
event.preventDefault();
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
event.preventDefault();
|
|
152
|
+
void submitForm(form, /** @type {HTMLElement | null} */ (event.submitter));
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Tek bir formu bağlar.
|
|
157
|
+
*
|
|
158
|
+
* `data-target` verilirse başarılı yanıtın gövdesi o bölgeye takılır; yoksa
|
|
159
|
+
* yanıt formun kendisini değiştirir (doğrulama hatalarının geldiği yol).
|
|
160
|
+
*
|
|
161
|
+
* @param {HTMLFormElement} form
|
|
162
|
+
* @returns {() => void} Dinleyiciyi söken fonksiyon.
|
|
163
|
+
*/
|
|
164
|
+
export function enhanceForm(form) {
|
|
165
|
+
/** @param {Event} event */
|
|
166
|
+
const listener = (event) => onSubmit(/** @type {SubmitEvent} */ (event), form);
|
|
167
|
+
|
|
168
|
+
form.addEventListener("submit", listener);
|
|
169
|
+
return () => form.removeEventListener("submit", listener);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* `data-enhance` taşıyan formları tek bir delege dinleyiciyle bağlar.
|
|
174
|
+
* `submit` olayı köpürdüğü için sonradan DOM'a giren (fragment ile gelen)
|
|
175
|
+
* formlar da kapsanır ve her takastan sonra yeniden bağlama gerekmez.
|
|
176
|
+
*
|
|
177
|
+
* @param {ParentNode} [root]
|
|
178
|
+
* @returns {() => void}
|
|
179
|
+
*/
|
|
180
|
+
export function startForms(root = document) {
|
|
181
|
+
/** @param {Event} event */
|
|
182
|
+
const listener = (event) => {
|
|
183
|
+
const form = event.target;
|
|
184
|
+
if (!(form instanceof HTMLFormElement)) return;
|
|
185
|
+
if (!("enhance" in form.dataset)) return;
|
|
186
|
+
|
|
187
|
+
onSubmit(/** @type {SubmitEvent} */ (event), form);
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
root.addEventListener("submit", listener);
|
|
191
|
+
return () => root.removeEventListener("submit", listener);
|
|
192
|
+
}
|
package/src/client/index.js
CHANGED
|
@@ -11,7 +11,16 @@
|
|
|
11
11
|
*
|
|
12
12
|
* start();
|
|
13
13
|
*/
|
|
14
|
-
export {
|
|
14
|
+
export {
|
|
15
|
+
register,
|
|
16
|
+
registerAll,
|
|
17
|
+
hydrate,
|
|
18
|
+
unmount,
|
|
19
|
+
observeDocument,
|
|
20
|
+
start,
|
|
21
|
+
} from "./registry.js";
|
|
22
|
+
export { swap, startSwapLinks } from "./swap.js";
|
|
23
|
+
export { enhanceForm, startForms } from "./form.js";
|
|
15
24
|
export { createStore } from "./store.js";
|
|
16
25
|
export {
|
|
17
26
|
debounce,
|
package/src/client/registry.js
CHANGED
|
@@ -28,6 +28,18 @@ const loaders = new Map();
|
|
|
28
28
|
/** @type {WeakMap<HTMLElement, Set<string>>} */
|
|
29
29
|
const mounted = new WeakMap();
|
|
30
30
|
|
|
31
|
+
/**
|
|
32
|
+
* `mount()` bir temizlik fonksiyonu döndürebiliyor; DOM'dan çıkan island'ın
|
|
33
|
+
* dinleyicilerini ve zamanlayıcılarını sökmek için saklanır.
|
|
34
|
+
*
|
|
35
|
+
* WeakMap: sökülmeden çöpe giden bir element (ör. tüm sayfa gezinmesi)
|
|
36
|
+
* burada referans bırakmasın. Alt ağacı gezme işi DOM üzerinden yapılıyor,
|
|
37
|
+
* bu yüzden ayrı bir element listesine gerek yok.
|
|
38
|
+
*
|
|
39
|
+
* @type {WeakMap<HTMLElement, (() => void)[]>}
|
|
40
|
+
*/
|
|
41
|
+
const cleanups = new WeakMap();
|
|
42
|
+
|
|
31
43
|
/**
|
|
32
44
|
* @param {string} name
|
|
33
45
|
* @param {() => Promise<{ mount: MountFn }>} loader
|
|
@@ -55,7 +67,7 @@ function readProps(element) {
|
|
|
55
67
|
return JSON.parse(raw);
|
|
56
68
|
} catch (error) {
|
|
57
69
|
console.error(
|
|
58
|
-
`[island] ${element.dataset.island}:
|
|
70
|
+
`[island] ${element.dataset.island}: invalid data-island-props`,
|
|
59
71
|
error,
|
|
60
72
|
);
|
|
61
73
|
return {};
|
|
@@ -92,7 +104,7 @@ async function mountIsland(element) {
|
|
|
92
104
|
|
|
93
105
|
const loader = loaders.get(name);
|
|
94
106
|
if (!loader) {
|
|
95
|
-
console.warn(`[island]
|
|
107
|
+
console.warn(`[island] not registered: ${name}`);
|
|
96
108
|
return;
|
|
97
109
|
}
|
|
98
110
|
|
|
@@ -100,10 +112,72 @@ async function mountIsland(element) {
|
|
|
100
112
|
|
|
101
113
|
try {
|
|
102
114
|
const module = await loader();
|
|
103
|
-
module.mount(element, readProps(element));
|
|
115
|
+
const cleanup = module.mount(element, readProps(element));
|
|
116
|
+
|
|
117
|
+
if (typeof cleanup === "function") {
|
|
118
|
+
// Island bağlanırken DOM'dan çıkarılmış olabilir (hızlı bir fragment
|
|
119
|
+
// takası). Temizliği saklamak yerine hemen çalıştırmak gerekiyor;
|
|
120
|
+
// aksi hâlde artık görünmeyen bir elementin dinleyicileri hiç sökülmez.
|
|
121
|
+
if (element.isConnected) {
|
|
122
|
+
const list = cleanups.get(element) ?? [];
|
|
123
|
+
list.push(cleanup);
|
|
124
|
+
cleanups.set(element, list);
|
|
125
|
+
} else {
|
|
126
|
+
runCleanups(element, cleanup);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
104
130
|
element.dataset.islandReady = "true";
|
|
105
131
|
} catch (error) {
|
|
106
|
-
console.error(`[island] ${name}
|
|
132
|
+
console.error(`[island] ${name} failed to load`, error);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* @param {HTMLElement} element
|
|
138
|
+
* @param {() => void} [extra] Henüz saklanmamış temizlik.
|
|
139
|
+
*/
|
|
140
|
+
function runCleanups(element, extra) {
|
|
141
|
+
const stored = cleanups.get(element) ?? [];
|
|
142
|
+
cleanups.delete(element);
|
|
143
|
+
mounted.delete(element);
|
|
144
|
+
delete element.dataset.islandReady;
|
|
145
|
+
|
|
146
|
+
for (const cleanup of extra ? [...stored, extra] : stored) {
|
|
147
|
+
try {
|
|
148
|
+
cleanup();
|
|
149
|
+
} catch (error) {
|
|
150
|
+
console.error(`[island] ${element.dataset.island} cleanup failed`, error);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Bir alt ağaçtaki island'ları söker.
|
|
157
|
+
*
|
|
158
|
+
* Fragment takasında çağrılması zorunlu: `innerHTML` ile değiştirilen bir
|
|
159
|
+
* bölgenin island'ları DOM'dan çıkar ama `document`/`window` üzerine
|
|
160
|
+
* kurdukları dinleyiciler ve `setInterval`'ları yaşamaya devam eder. Birkaç
|
|
161
|
+
* takastan sonra aynı olay birden fazla kez işlenmeye başlar.
|
|
162
|
+
*
|
|
163
|
+
* Sökülen element yeniden bağlanabilir hâle gelir: `mounted` kaydı da
|
|
164
|
+
* temizlenir, böylece aynı düğüm tekrar DOM'a girerse `hydrate()` onu
|
|
165
|
+
* yeniden görür.
|
|
166
|
+
*
|
|
167
|
+
* @param {ParentNode} [root] Kökün kendisi de island olabilir.
|
|
168
|
+
*/
|
|
169
|
+
export function unmount(root = document) {
|
|
170
|
+
const element = /** @type {HTMLElement} */ (root);
|
|
171
|
+
|
|
172
|
+
if (element.dataset?.island) {
|
|
173
|
+
lazyObserver?.unobserve(element);
|
|
174
|
+
runCleanups(element);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
for (const node of root.querySelectorAll?.("[data-island]") ?? []) {
|
|
178
|
+
const child = /** @type {HTMLElement} */ (node);
|
|
179
|
+
lazyObserver?.unobserve(child);
|
|
180
|
+
runCleanups(child);
|
|
107
181
|
}
|
|
108
182
|
}
|
|
109
183
|
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sunucudan gelen bir parçayı DOM'a takar.
|
|
3
|
+
*
|
|
4
|
+
* Dashboard'un can damarı bu: tablo sayfası, filtre sonucu, yeniden hesaplanan
|
|
5
|
+
* kart — hepsi sunucuda render edilip yerine konur, böylece işaretlemenin tek
|
|
6
|
+
* kaynağı sunucuda kalır ve istemci ikinci bir şablon taşımaz.
|
|
7
|
+
*
|
|
8
|
+
* Taşıma katmanından bağımsız. Verinin ne zaman değiştiğini nasıl öğrendiğin
|
|
9
|
+
* (SSE, WebSocket, aralıklı sorgu, kullanıcı tıklaması) uygulamanın kararı;
|
|
10
|
+
* burada yalnızca "şu parçayı tazele" adımı var.
|
|
11
|
+
*
|
|
12
|
+
* Sırası önemli olan üç iş yapıyor:
|
|
13
|
+
* 1. eski alt ağacın island'larını söker (yoksa dinleyici sızar),
|
|
14
|
+
* 2. içeriği değiştirir,
|
|
15
|
+
* 3. yeni alt ağacı hidre eder ve odağı kaybolmuşsa geri getirir.
|
|
16
|
+
*/
|
|
17
|
+
import { hydrate, unmount } from "./registry.js";
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* @param {Element | string} target
|
|
21
|
+
* @returns {HTMLElement | null}
|
|
22
|
+
*/
|
|
23
|
+
function resolveTarget(target) {
|
|
24
|
+
if (typeof target === "string") {
|
|
25
|
+
return /** @type {HTMLElement | null} */ (document.querySelector(target));
|
|
26
|
+
}
|
|
27
|
+
return /** @type {HTMLElement} */ (target);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Odak takas edilen bölgenin içindeyse kaybolur ve klavye kullanıcısı
|
|
32
|
+
* sayfanın başına düşer. Yeni içerikte aynı `id`'li bir element varsa oraya,
|
|
33
|
+
* yoksa bölgenin kendisine dönüyoruz.
|
|
34
|
+
*
|
|
35
|
+
* @param {HTMLElement} element
|
|
36
|
+
* @param {string | null} previousId
|
|
37
|
+
*/
|
|
38
|
+
function restoreFocus(element, previousId) {
|
|
39
|
+
if (previousId) {
|
|
40
|
+
const next = element.querySelector(`#${CSS.escape(previousId)}`);
|
|
41
|
+
if (next instanceof HTMLElement) {
|
|
42
|
+
next.focus();
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (!element.hasAttribute("tabindex")) {
|
|
48
|
+
element.setAttribute("tabindex", "-1");
|
|
49
|
+
}
|
|
50
|
+
element.focus({ preventScroll: true });
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* @typedef {object} SwapOptions
|
|
55
|
+
* @property {string} [method] Varsayılan `GET`.
|
|
56
|
+
* @property {BodyInit | null} [body]
|
|
57
|
+
* @property {Record<string, string>} [headers]
|
|
58
|
+
* @property {AbortSignal} [signal]
|
|
59
|
+
* @property {boolean} [history] `true` → adres çubuğu `url` ile güncellenir.
|
|
60
|
+
* @property {"replace" | "append"} [mode] Varsayılan `replace`.
|
|
61
|
+
*/
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* @param {Element | string} target Takas edilecek bölge.
|
|
65
|
+
* @param {string} url Parçayı döndüren uç.
|
|
66
|
+
* @param {SwapOptions} [options]
|
|
67
|
+
* @returns {Promise<boolean>} Başarılıysa `true`; hata durumunda mevcut
|
|
68
|
+
* içerik korunur ve `false` döner.
|
|
69
|
+
*/
|
|
70
|
+
export async function swap(target, url, options = {}) {
|
|
71
|
+
const element = resolveTarget(target);
|
|
72
|
+
if (!element) {
|
|
73
|
+
console.warn(`[swap] target not found: ${String(target)}`);
|
|
74
|
+
return false;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const activeId =
|
|
78
|
+
document.activeElement instanceof HTMLElement &&
|
|
79
|
+
element.contains(document.activeElement)
|
|
80
|
+
? document.activeElement.id || null
|
|
81
|
+
: null;
|
|
82
|
+
|
|
83
|
+
element.setAttribute("aria-busy", "true");
|
|
84
|
+
|
|
85
|
+
try {
|
|
86
|
+
const response = await fetch(url, {
|
|
87
|
+
method: options.method ?? "GET",
|
|
88
|
+
body: options.body ?? null,
|
|
89
|
+
headers: {
|
|
90
|
+
// Sunucu tam sayfa yerine parça döndürmeyi seçebilsin.
|
|
91
|
+
"X-Requested-With": "fragment",
|
|
92
|
+
...(options.headers ?? {}),
|
|
93
|
+
},
|
|
94
|
+
// Oturum cookie'si olmadan kişiye özel parça gelmez.
|
|
95
|
+
credentials: "same-origin",
|
|
96
|
+
signal: options.signal,
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
// Sunucu yönlendirdiyse (ör. oturum düştü, login'e gidiliyor) parça değil
|
|
100
|
+
// sayfa beklenir; alt ağaca bir login formu takmak anlamsız olurdu.
|
|
101
|
+
if (response.redirected && response.url !== new URL(url, location.href).href) {
|
|
102
|
+
location.assign(response.url);
|
|
103
|
+
return false;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (!response.ok) {
|
|
107
|
+
element.dispatchEvent(
|
|
108
|
+
new CustomEvent("jskelet:swap-error", {
|
|
109
|
+
bubbles: true,
|
|
110
|
+
detail: { url, status: response.status },
|
|
111
|
+
}),
|
|
112
|
+
);
|
|
113
|
+
return false;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const html = await response.text();
|
|
117
|
+
|
|
118
|
+
if (options.mode === "append") {
|
|
119
|
+
const holder = document.createElement("div");
|
|
120
|
+
holder.innerHTML = html;
|
|
121
|
+
const added = [...holder.childNodes];
|
|
122
|
+
element.append(...added);
|
|
123
|
+
for (const node of added) {
|
|
124
|
+
if (node instanceof HTMLElement) hydrate(node);
|
|
125
|
+
}
|
|
126
|
+
} else {
|
|
127
|
+
unmount(element);
|
|
128
|
+
element.innerHTML = html;
|
|
129
|
+
hydrate(element);
|
|
130
|
+
restoreFocus(element, activeId);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
if (options.history) {
|
|
134
|
+
history.pushState({}, "", url);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
element.dispatchEvent(
|
|
138
|
+
new CustomEvent("jskelet:swap", { bubbles: true, detail: { url } }),
|
|
139
|
+
);
|
|
140
|
+
|
|
141
|
+
return true;
|
|
142
|
+
} catch (error) {
|
|
143
|
+
// İptal edilen istek hata değil: kullanıcı hızlı yazıyor ya da sayfadan
|
|
144
|
+
// ayrıldı.
|
|
145
|
+
if (!(error instanceof DOMException && error.name === "AbortError")) {
|
|
146
|
+
console.error(`[swap] ${url}`, error);
|
|
147
|
+
element.dispatchEvent(
|
|
148
|
+
new CustomEvent("jskelet:swap-error", {
|
|
149
|
+
bubbles: true,
|
|
150
|
+
detail: { url, error },
|
|
151
|
+
}),
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
return false;
|
|
155
|
+
} finally {
|
|
156
|
+
element.removeAttribute("aria-busy");
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* `data-swap` taşıyan bağlantı ve butonları otomatik bağlar.
|
|
162
|
+
*
|
|
163
|
+
* <a href="/_fragment/rows?page=2" data-swap="#rows" data-swap-history>…</a>
|
|
164
|
+
*
|
|
165
|
+
* Tek bir delege dinleyici: sonradan DOM'a giren bağlantılar da çalışır ve
|
|
166
|
+
* her takastan sonra yeniden bağlama gerekmez.
|
|
167
|
+
*
|
|
168
|
+
* @param {ParentNode} [root]
|
|
169
|
+
* @returns {() => void} Dinleyiciyi söken fonksiyon.
|
|
170
|
+
*/
|
|
171
|
+
export function startSwapLinks(root = document) {
|
|
172
|
+
/** @param {Event} event */
|
|
173
|
+
const onClick = (event) => {
|
|
174
|
+
const origin = /** @type {HTMLElement | null} */ (event.target);
|
|
175
|
+
const trigger = origin?.closest?.("[data-swap]");
|
|
176
|
+
if (!(trigger instanceof HTMLElement)) return;
|
|
177
|
+
|
|
178
|
+
const selector = trigger.dataset.swap;
|
|
179
|
+
const url = trigger.getAttribute("href") ?? trigger.dataset.swapUrl;
|
|
180
|
+
if (!selector || !url) return;
|
|
181
|
+
|
|
182
|
+
event.preventDefault();
|
|
183
|
+
void swap(selector, url, { history: "swapHistory" in trigger.dataset });
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
root.addEventListener("click", onClick);
|
|
187
|
+
return () => root.removeEventListener("click", onClick);
|
|
188
|
+
}
|
package/src/config/defaults.js
CHANGED
|
@@ -55,6 +55,55 @@ export const DEFAULT_PREWARM = {
|
|
|
55
55
|
concurrency: 4,
|
|
56
56
|
/** İki tur arasında beklenen süre: upstream'e ani yük binmesin. */
|
|
57
57
|
delayMs: 0,
|
|
58
|
+
/**
|
|
59
|
+
* Saniyedeki en fazla ısıtma isteği. 0 → sınırsız (yalnızca `concurrency`
|
|
60
|
+
* frenler). Upstream'i kota sınırının altında tutmanın en doğrudan yolu bu:
|
|
61
|
+
* paralellik ne kadar yükselse de tur bu hızın üstüne çıkmaz.
|
|
62
|
+
*/
|
|
63
|
+
rps: 0,
|
|
64
|
+
/**
|
|
65
|
+
* Tekrar turundan önce beklenen süre. Rate limit pencereleri saniye
|
|
66
|
+
* mertebesinde; hemen tekrar denemek aynı 429'u almak demek.
|
|
67
|
+
*/
|
|
68
|
+
retryDelayMs: 2000,
|
|
69
|
+
/**
|
|
70
|
+
* Liste `max`'tan uzunsa periyodik turlar kaldığı yerden devam eder.
|
|
71
|
+
* Böylece 10.000 yolluk bir site tek turda değil, turlar boyunca ısınır.
|
|
72
|
+
* `priority` eşleşen yollar her turda ısıtıldığı için rotasyon yalnızca
|
|
73
|
+
* kuyruğu dolaşır.
|
|
74
|
+
*/
|
|
75
|
+
rotate: true,
|
|
76
|
+
/**
|
|
77
|
+
* Isıtma sırasını belirleyen desenler. String (`/haber/:slug`) ya da
|
|
78
|
+
* `RegExp` kabul eder; önce yazılan önce ısınır.
|
|
79
|
+
* @type {(string | RegExp)[]}
|
|
80
|
+
*/
|
|
81
|
+
priority: [],
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* HTML önbelleğinin girdi sınırı. 500 girdi ortalama bir sayfa boyutunda
|
|
86
|
+
* yaklaşık 100-200 MB tutar; uzun kuyruklu siteler bunu yükseltmek yerine
|
|
87
|
+
* veri önbelleğine yaslanmalı (bkz. `DEFAULT_DATA_CACHE`).
|
|
88
|
+
*/
|
|
89
|
+
export const DEFAULT_HTML_CACHE_MAX_ENTRIES = 500;
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Upstream veri önbelleği.
|
|
93
|
+
*
|
|
94
|
+
* HTML önbelleğinden bilinçli olarak çok daha büyük: JSON, aynı sayfanın
|
|
95
|
+
* HTML'ine göre onlarca kat küçük. Uzun kuyruğu (on binlerce haber/etiket)
|
|
96
|
+
* HTML olarak tutmak imkânsız, verisini tutmak ise ucuz — ve API kotasını
|
|
97
|
+
* koruyan katman burası.
|
|
98
|
+
*/
|
|
99
|
+
export const DEFAULT_DATA_CACHE = {
|
|
100
|
+
maxEntries: 10000,
|
|
101
|
+
/**
|
|
102
|
+
* TTL dolduktan sonra girdinin kaç TTL boyunca daha kullanılabileceği.
|
|
103
|
+
* HTML'deki 1 katsayısından yüksek: bayat veri, eksik sayfadan iyidir ve
|
|
104
|
+
* upstream düştüğünde tek elde kalan şey budur.
|
|
105
|
+
*/
|
|
106
|
+
staleFactor: 10,
|
|
58
107
|
};
|
|
59
108
|
|
|
60
109
|
/** Oturuma bağlı sayfalar ısıtılmaz; uygulama kendi listesini verebilir. */
|
|
@@ -86,6 +135,40 @@ export const DEFAULT_NAVIGATION = {
|
|
|
86
135
|
*/
|
|
87
136
|
export const DEFAULT_NAVIGATION_EXCLUDE = ["/api/*", "/_fragment/*"];
|
|
88
137
|
|
|
138
|
+
/**
|
|
139
|
+
* Güvenlik ayarları.
|
|
140
|
+
*
|
|
141
|
+
* `trustProxy` varsayılan olarak açık, çünkü JSkelet uygulamaları neredeyse
|
|
142
|
+
* her zaman bir ters proxy arkasında koşuyor ve doğru protokol/IP buna bağlı.
|
|
143
|
+
* Ama doğrudan internete açık bir sunucuda bu, istemcinin `X-Forwarded-For`
|
|
144
|
+
* uydurabilmesi demek — rate limit ve audit log girdiğinde kapatılmalı.
|
|
145
|
+
*
|
|
146
|
+
* `csrf.enabled` açık: framework gövdeyi kendisi ayrıştırdığı için bu yüzey
|
|
147
|
+
* onun sorumluluğu. Kontrol yalnızca **çapraz site olduğu belli** istekleri
|
|
148
|
+
* reddeder (`Origin` uyuşmuyor ya da `Sec-Fetch-Site: cross-site`); başlık
|
|
149
|
+
* hiç yoksa geçer, böylece webhook ve sunucudan sunucuya çağrılar bozulmaz.
|
|
150
|
+
*
|
|
151
|
+
* `csrf.token` kapalı: çift gönderim token'ı `Origin` göndermeyen eski
|
|
152
|
+
* tarayıcılar için ikinci katman ve formlara `csrfField()` eklenmesini
|
|
153
|
+
* gerektiriyor, yani açılması bilinçli bir karar olmalı.
|
|
154
|
+
*/
|
|
155
|
+
export const DEFAULT_SECURITY = {
|
|
156
|
+
trustProxy: true,
|
|
157
|
+
/** @type {string | null} */
|
|
158
|
+
cookieSecret: null,
|
|
159
|
+
csrf: {
|
|
160
|
+
enabled: true,
|
|
161
|
+
token: false,
|
|
162
|
+
/** Ek olarak kabul edilen origin'ler (ör. ayrı bir admin alan adı). */
|
|
163
|
+
allowedOrigins: /** @type {string[]} */ ([]),
|
|
164
|
+
/** Kontrolden muaf yollar — webhook uçları buraya yazılır. */
|
|
165
|
+
exclude: /** @type {string[]} */ ([]),
|
|
166
|
+
cookieName: "csrf_token",
|
|
167
|
+
fieldName: "_csrf",
|
|
168
|
+
headerName: "x-csrf-token",
|
|
169
|
+
},
|
|
170
|
+
};
|
|
171
|
+
|
|
89
172
|
/**
|
|
90
173
|
* Markalama. Header adı ve dev overlay yolu tek yerden değişsin diye
|
|
91
174
|
* config'ten okunur — fork eden proje kendi adını verebilir.
|