jskelet 0.2.4 → 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.
@@ -1,12 +1,13 @@
1
1
  /**
2
- * Önbellek panelinin istemci tarafı.
2
+ * Admin panelinin istemci tarafı.
3
3
  *
4
4
  * Build hattından geçmez, doğrudan diskten servis edilir: panelin açık olduğu
5
5
  * bir kurulumda `jskelet build` hiç koşmamış olabilir ve panel yine çalışmalı.
6
6
  * Bu yüzden burada import edilen bir framework modülü yok.
7
7
  *
8
8
  * Yenileme WebSocket değil, kısa aralıklı `fetch` ile: panel nadiren ve kısa
9
- * süre açık kalıyor, kalıcı bir kanal açmanın karşılığı yok.
9
+ * süre açık kalıyor, kalıcı bir kanal açmanın karşılığı yok. Log sayfası
10
+ * ayrıca SSE kullanır.
10
11
  *
11
12
  * Bütün görünen metin `i18n.js` üzerinden geçiyor; sunucudan gelen cevaplar da
12
13
  * metin değil `code` taşıyor, yani dil değişimi tek yerden hallediliyor.
@@ -14,6 +15,7 @@
14
15
  import { applyTranslations, languageSelect, t } from "./i18n.js";
15
16
 
16
17
  const REFRESH_MS = 3000;
18
+ const PAGES = new Set(["overview", "cache", "routes", "views", "logs", "system"]);
17
19
 
18
20
  const $ = (/** @type {string} */ id) => document.getElementById(id);
19
21
 
@@ -25,6 +27,17 @@ let latest = null;
25
27
  /** @type {number | null} */
26
28
  let timer = null;
27
29
  let busy = false;
30
+ /** @type {string} */
31
+ let page = "overview";
32
+ /** @type {any} */
33
+ let routesPayload = null;
34
+ /** @type {any} */
35
+ let viewsPayload = null;
36
+ /** @type {any[]} */
37
+ let logBuffer = [];
38
+ /** @type {EventSource | null} */
39
+ let logSource = null;
40
+ let logsPaused = false;
28
41
 
29
42
  /* ------------------------------------------------------------ biçimlendirme */
30
43
 
@@ -118,7 +131,7 @@ async function act(body) {
118
131
  headers: {
119
132
  "Content-Type": "application/json",
120
133
  // Panelin CSRF freni: çapraz siteden gönderilemeyen bir başlık.
121
- "X-JSkelet-Cache-Panel": "1",
134
+ "X-JSkelet-Admin": "1",
122
135
  },
123
136
  body: JSON.stringify(body),
124
137
  });
@@ -206,6 +219,7 @@ function render() {
206
219
 
207
220
  renderRedis(redis, redisState);
208
221
  renderHost(host, proc, redis);
222
+ renderUpstream(latest.upstream);
209
223
 
210
224
  const done = prewarm.done ?? 0;
211
225
  const total = prewarm.total ?? 0;
@@ -219,6 +233,49 @@ function render() {
219
233
  renderTable();
220
234
  }
221
235
 
236
+ /**
237
+ * @param {any} upstream
238
+ */
239
+ function renderUpstream(upstream) {
240
+ const tbody = $("upstream-tbody");
241
+ const empty = $("upstream-empty");
242
+ const heading = $("upstream-heading");
243
+ if (!tbody || !empty || !heading) return;
244
+
245
+ const hosts = Array.isArray(upstream) ? upstream : [];
246
+ heading.textContent = String(hosts.length);
247
+ tbody.replaceChildren();
248
+
249
+ if (!hosts.length) {
250
+ empty.hidden = false;
251
+ return;
252
+ }
253
+
254
+ empty.hidden = true;
255
+ for (const info of hosts) {
256
+ const row = document.createElement("tr");
257
+ const state = info?.bypassed
258
+ ? "bypassed"
259
+ : info?.blockedMs > 0
260
+ ? "blocked"
261
+ : `${info?.active ?? 0} active`;
262
+ row.innerHTML = `<td class="mono">${escapeHtml(String(info.host ?? ""))}</td><td class="mono">${escapeHtml(String(info.rate ?? "—"))}</td><td class="mono">${escapeHtml(String(state))}</td>`;
263
+ tbody.append(row);
264
+ }
265
+ }
266
+
267
+ /**
268
+ * @param {string} value
269
+ * @returns {string}
270
+ */
271
+ function escapeHtml(value) {
272
+ return value
273
+ .replaceAll("&", "&amp;")
274
+ .replaceAll("<", "&lt;")
275
+ .replaceAll(">", "&gt;")
276
+ .replaceAll('"', "&quot;");
277
+ }
278
+
222
279
  /* ------------------------------------------------------------- cloudflare */
223
280
 
224
281
  /** @type {any} */
@@ -352,7 +409,7 @@ async function loadCloudflareAnalytics() {
352
409
  method: "POST",
353
410
  headers: {
354
411
  "Content-Type": "application/json",
355
- "X-JSkelet-Cache-Panel": "1",
412
+ "X-JSkelet-Admin": "1",
356
413
  },
357
414
  body: JSON.stringify({ path }),
358
415
  });
@@ -888,11 +945,273 @@ function flip(value) {
888
945
  // Sekme arkaya alındığında yoklama durur: panel açık kalmış bir sekmede
889
946
  // dakikalarca boşa istek atmasın.
890
947
  document.addEventListener("visibilitychange", () => {
891
- if (document.hidden) stopPolling();
892
- else if ($("auto").checked) {
948
+ if (document.hidden) {
949
+ stopPolling();
950
+ stopLogStream();
951
+ } else if ($("auto").checked) {
893
952
  void load();
894
953
  startPolling();
954
+ if (page === "logs") startLogStream();
955
+ }
956
+ });
957
+
958
+ /* -------------------------------------------------------------- sayfalar */
959
+
960
+ /**
961
+ * URL yolunun son segmentinden sayfa adı.
962
+ * @returns {string}
963
+ */
964
+ function pageFromLocation() {
965
+ const parts = location.pathname.replace(/\/+$/, "").split("/");
966
+ const last = parts[parts.length - 1] || "";
967
+ if (PAGES.has(last)) return last;
968
+ return "overview";
969
+ }
970
+
971
+ /**
972
+ * @param {string} next
973
+ * @param {{ push?: boolean }} [options]
974
+ */
975
+ function showPage(next, options = {}) {
976
+ const name = PAGES.has(next) ? next : "overview";
977
+ page = name;
978
+
979
+ for (const node of document.querySelectorAll(".page")) {
980
+ const el = /** @type {HTMLElement} */ (node);
981
+ el.hidden = el.dataset.page !== name;
982
+ }
983
+
984
+ for (const link of document.querySelectorAll("#nav a")) {
985
+ const a = /** @type {HTMLAnchorElement} */ (link);
986
+ if (a.dataset.page === name) a.setAttribute("aria-current", "page");
987
+ else a.removeAttribute("aria-current");
988
+ }
989
+
990
+ const sse = $("sse-status");
991
+ if (sse) sse.hidden = name !== "logs";
992
+
993
+ if (options.push !== false) {
994
+ const target = name === "overview" ? "./" : name;
995
+ history.pushState({ page: name }, "", target);
996
+ }
997
+
998
+ if (name === "routes") void loadRoutes();
999
+ if (name === "views") void loadViews();
1000
+ if (name === "logs") startLogStream();
1001
+ else stopLogStream();
1002
+ }
1003
+
1004
+ async function loadRoutes() {
1005
+ const response = await fetch("api/routes");
1006
+ if (!alive(response)) return;
1007
+ if (!response.ok) return;
1008
+ routesPayload = await response.json();
1009
+ renderRoutes();
1010
+ }
1011
+
1012
+ async function loadViews() {
1013
+ const response = await fetch("api/views");
1014
+ if (!alive(response)) return;
1015
+ if (!response.ok) return;
1016
+ viewsPayload = await response.json();
1017
+ renderViews();
1018
+ }
1019
+
1020
+ function renderRoutes() {
1021
+ if (!routesPayload) return;
1022
+
1023
+ const routes = routesPayload.routes ?? [];
1024
+ const modules = routesPayload.modules ?? [];
1025
+ const activity = routesPayload.activity ?? {};
1026
+
1027
+ $("routes-count").textContent = String(routes.length);
1028
+ $("modules-count").textContent = String(modules.length);
1029
+
1030
+ const tbody = $("routes-tbody");
1031
+ const empty = $("routes-empty");
1032
+ tbody.replaceChildren();
1033
+
1034
+ if (!routes.length) {
1035
+ empty.hidden = false;
1036
+ } else {
1037
+ empty.hidden = true;
1038
+ for (const route of routes) {
1039
+ const key = route.path;
1040
+ const act = activity[key] ?? null;
1041
+ const row = document.createElement("tr");
1042
+ row.innerHTML = `
1043
+ <td class="mono">${escapeHtml(route.method)}</td>
1044
+ <td class="mono"><button type="button" class="linkish" data-path="${escapeHtml(route.path)}">${escapeHtml(route.path)}</button></td>
1045
+ <td class="mono">${act ? act.status : "—"}</td>
1046
+ <td class="mono">${act ? Math.round(act.ms) : "—"}</td>
1047
+ <td class="mono">${act ? act.count : "—"}</td>`;
1048
+ tbody.append(row);
1049
+ }
1050
+
1051
+ tbody.querySelectorAll("button[data-path]").forEach((button) => {
1052
+ button.addEventListener("click", () => {
1053
+ const pathFilter = /** @type {HTMLElement} */ (button).dataset.path ?? "";
1054
+ $("logs-path").value = pathFilter;
1055
+ showPage("logs");
1056
+ renderLogs();
1057
+ });
1058
+ });
1059
+ }
1060
+
1061
+ const modulesBody = $("modules-tbody");
1062
+ modulesBody.replaceChildren();
1063
+ for (const mod of modules) {
1064
+ const row = document.createElement("tr");
1065
+ row.innerHTML = `<td class="mono">${escapeHtml(mod.relative)}</td>`;
1066
+ modulesBody.append(row);
1067
+ }
1068
+ }
1069
+
1070
+ function renderViews() {
1071
+ if (!viewsPayload) return;
1072
+
1073
+ const term = ($("views-search")?.value ?? "").trim().toLowerCase();
1074
+ const views = (viewsPayload.views ?? []).filter(
1075
+ (/** @type {{ relative: string }} */ view) =>
1076
+ !term || view.relative.toLowerCase().includes(term),
1077
+ );
1078
+
1079
+ $("views-count").textContent = String(views.length);
1080
+ const tbody = $("views-tbody");
1081
+ const empty = $("views-empty");
1082
+ tbody.replaceChildren();
1083
+
1084
+ if (!views.length) {
1085
+ empty.hidden = false;
1086
+ return;
1087
+ }
1088
+
1089
+ empty.hidden = true;
1090
+ for (const view of views) {
1091
+ const row = document.createElement("tr");
1092
+ row.innerHTML = `<td class="mono">${escapeHtml(view.relative)}</td><td>${escapeHtml(view.kind)}</td>`;
1093
+ tbody.append(row);
1094
+ }
1095
+ }
1096
+
1097
+ function startLogStream() {
1098
+ if (logSource) return;
1099
+
1100
+ const chip = $("sse-status");
1101
+ if (chip) {
1102
+ chip.hidden = false;
1103
+ chip.textContent = t("chip.sse.live");
1104
+ }
1105
+
1106
+ logSource = new EventSource("api/logs/stream");
1107
+ logSource.onmessage = (event) => {
1108
+ try {
1109
+ const entry = JSON.parse(event.data);
1110
+ if (!logsPaused) {
1111
+ logBuffer.push(entry);
1112
+ if (logBuffer.length > 1000) logBuffer.shift();
1113
+ renderLogs();
1114
+ }
1115
+ } catch {
1116
+ // bozuk çerçeve yok sayılır
1117
+ }
1118
+ };
1119
+ logSource.onerror = () => {
1120
+ if (chip) chip.textContent = t("chip.sse.error");
1121
+ };
1122
+ }
1123
+
1124
+ function stopLogStream() {
1125
+ if (logSource) {
1126
+ logSource.close();
1127
+ logSource = null;
1128
+ }
1129
+ const chip = $("sse-status");
1130
+ if (chip) {
1131
+ chip.textContent = t("chip.sse.off");
1132
+ chip.hidden = page !== "logs";
1133
+ }
1134
+ }
1135
+
1136
+ function renderLogs() {
1137
+ const list = $("logs-list");
1138
+ const empty = $("logs-empty");
1139
+ const count = $("logs-count");
1140
+ if (!list || !empty || !count) return;
1141
+
1142
+ const q = ($("logs-q")?.value ?? "").trim().toLowerCase();
1143
+ const kind = $("logs-kind")?.value ?? "";
1144
+ const method = $("logs-method")?.value ?? "";
1145
+ const statusClass = $("logs-status")?.value ?? "";
1146
+ const cache = $("logs-cache")?.value ?? "";
1147
+ const pathFilter = ($("logs-path")?.value ?? "").trim().toLowerCase();
1148
+
1149
+ const filtered = logBuffer.filter((entry) => {
1150
+ if (kind && entry.kind !== kind) return false;
1151
+ if (method && entry.method !== method) return false;
1152
+ if (statusClass && entry.status != null) {
1153
+ if (String(Math.floor(Number(entry.status) / 100)) !== statusClass) return false;
1154
+ }
1155
+ if (cache && entry.cache !== cache) return false;
1156
+ if (pathFilter) {
1157
+ const hay = `${entry.path ?? ""} ${entry.route ?? ""} ${entry.url ?? ""}`.toLowerCase();
1158
+ if (!hay.includes(pathFilter)) return false;
1159
+ }
1160
+ if (q) {
1161
+ const hay = `${entry.url ?? ""} ${entry.message ?? ""} ${entry.scope ?? ""} ${entry.path ?? ""}`.toLowerCase();
1162
+ if (!hay.includes(q)) return false;
1163
+ }
1164
+ return true;
1165
+ });
1166
+
1167
+ count.textContent = t("logs.shown", { shown: filtered.length, total: logBuffer.length });
1168
+ empty.hidden = filtered.length > 0;
1169
+
1170
+ const lines = filtered.slice(-300).map(formatLogLine);
1171
+ list.textContent = lines.join("\n");
1172
+ list.scrollTop = list.scrollHeight;
1173
+ }
1174
+
1175
+ /**
1176
+ * @param {any} entry
1177
+ * @returns {string}
1178
+ */
1179
+ function formatLogLine(entry) {
1180
+ const time = new Date(entry.at).toISOString().slice(11, 19);
1181
+ if (entry.kind === "http") {
1182
+ const cache = entry.cache ? ` ${entry.cache}` : "";
1183
+ const route = entry.route ? ` [${entry.route}]` : "";
1184
+ return `${time} ${String(entry.method).padEnd(6)} ${entry.status} ${Math.round(entry.ms)}ms${cache}${route} ${entry.url}`;
895
1185
  }
1186
+ return `${time} ${entry.kind.padEnd(6)} ${entry.scope ?? ""} ${entry.message ?? ""}${entry.note ? ` ${entry.note}` : ""}`;
1187
+ }
1188
+
1189
+ for (const link of document.querySelectorAll("#nav a")) {
1190
+ link.addEventListener("click", (event) => {
1191
+ event.preventDefault();
1192
+ const next = /** @type {HTMLElement} */ (link).dataset.page ?? "overview";
1193
+ showPage(next);
1194
+ });
1195
+ }
1196
+
1197
+ window.addEventListener("popstate", () => {
1198
+ showPage(pageFromLocation(), { push: false });
1199
+ });
1200
+
1201
+ $("views-search")?.addEventListener("input", () => renderViews());
1202
+
1203
+ for (const id of ["logs-q", "logs-kind", "logs-method", "logs-status", "logs-cache", "logs-path"]) {
1204
+ $(id)?.addEventListener("input", () => renderLogs());
1205
+ $(id)?.addEventListener("change", () => renderLogs());
1206
+ }
1207
+
1208
+ $("logs-pause")?.addEventListener("change", (event) => {
1209
+ logsPaused = /** @type {HTMLInputElement} */ (event.target).checked;
1210
+ });
1211
+
1212
+ $("logs-clear")?.addEventListener("click", () => {
1213
+ logBuffer = [];
1214
+ renderLogs();
896
1215
  });
897
1216
 
898
1217
  /* -------------------------------------------------------------------- dil */
@@ -905,11 +1224,19 @@ $("language").append(
905
1224
  if (latest) render();
906
1225
  renderCloudflare();
907
1226
  renderReport();
1227
+ renderRoutes();
1228
+ renderViews();
1229
+ renderLogs();
908
1230
  }),
909
1231
  );
910
1232
 
911
1233
  applyTranslations();
912
1234
 
1235
+ const params = new URLSearchParams(location.search);
1236
+ if (params.get("path")) $("logs-path").value = params.get("path") ?? "";
1237
+ if (params.get("route")) $("logs-path").value = params.get("route") ?? "";
1238
+
1239
+ showPage(pageFromLocation(), { push: false });
913
1240
  void load();
914
1241
  void loadCloudflare();
915
1242
  startPolling();
@@ -192,27 +192,36 @@ export const DEFAULT_REDIS = {
192
192
  };
193
193
 
194
194
  /**
195
- * Önbellek yönetim paneli.
195
+ * Framework yönetim paneli (`/_jskelet/admin`).
196
196
  *
197
197
  * `enabled` varsayılan olarak **kapalı** ve ortama bakmaz: panel açıldığında
198
198
  * production'da da çalışır, ama açılması bilinçli bir karar olmalı. Kapalıyken
199
199
  * router hiç mount edilmez — yolun kendisi de yok, yani 404 dönen bir uç bile
200
200
  * ortaya çıkmaz.
201
201
  *
202
- * Şifre her süreç başlangıcında yeniden üretilir (bkz.
203
- * `src/server/cache-panel.js`): panelin ömrü sürecin ömrü kadardır ve bir
204
- * deploy eski erişimi otomatik olarak iptal eder. Bu yüzden config'te şifre
205
- * alanı yok.
202
+ * Şifre her süreç başlangıcında yeniden üretilir (bkz. `src/server/admin/`):
203
+ * panelin ömrü sürecin ömrü kadardır ve bir deploy eski erişimi otomatik
204
+ * olarak iptal eder. Bu yüzden config'te şifre alanı yok.
206
205
  */
207
- export const DEFAULT_CACHE_PANEL = {
206
+ export const DEFAULT_ADMIN = {
208
207
  enabled: false,
209
- basePath: "/_jskelet/cache",
208
+ basePath: "/_jskelet/admin",
209
+ /**
210
+ * Boş dizi = IP kısıtı yok. Exact IP veya CIDR (`10.0.0.0/8`); listede
211
+ * olmayan her istek 404 (login dahil).
212
+ * @type {string[]}
213
+ */
214
+ allowIps: [],
215
+ /** Bilinen crawler / bot UA'ları 404 ile reddedilsin mi. */
216
+ blockBots: true,
210
217
  /** Kaç başarısız denemeden sonra IP yasaklanır. */
211
218
  banAttempts: 3,
212
219
  /** Yasağın süresi. */
213
220
  banHours: 24,
214
221
  /** Oturumun ömrü; süreç yeniden başladığında zaten sıfırlanır. */
215
222
  sessionHours: 12,
223
+ /** Canlı log ring boyutu (HTTP + framework olayları). */
224
+ logSize: 500,
216
225
  };
217
226
 
218
227
  /**
@@ -17,8 +17,8 @@
17
17
  * rewrites() → [{ source, destination }] | { beforeFiles?, afterFiles? }
18
18
  * cache() → { html?: { [source]: saniye },
19
19
  * query?: { [source]: string[] | true }, maxEntries?: number,
20
- * data?: {...}, redis?: {...}, prewarm?: {...},
21
- * panel?: {...} }
20
+ * data?: {...}, redis?: {...}, prewarm?: {...} }
21
+ * admin() → { enabled?, basePath?, allowIps?, blockBots?, … }
22
22
  *
23
23
  * Fonksiyon olmayan bölümler (`brand`, `security`, `static`, `navigation`…)
24
24
  * düz nesne olarak okunur.
@@ -29,8 +29,8 @@ import process from "node:process";
29
29
  import { pathToFileURL } from "node:url";
30
30
  import { compilePattern, matchPattern } from "./pattern.js";
31
31
  import {
32
+ DEFAULT_ADMIN,
32
33
  DEFAULT_BRAND,
33
- DEFAULT_CACHE_PANEL,
34
34
  DEFAULT_CLOUDFLARE,
35
35
  DEFAULT_DATA_CACHE,
36
36
  DEFAULT_DEV_GATE_BYPASS,
@@ -96,7 +96,7 @@ const CONFIG_FILE = "jskelet.config.mjs";
96
96
  * @property {{ attempts: number, delayMs: number }} transientRetry
97
97
  * @property {RedisConfig} redis Opsiyonel Redis ikinci kademesi.
98
98
  * @property {typeof DEFAULT_UPSTREAM_LIMIT} upstream Upstream hız freni.
99
- * @property {typeof DEFAULT_CACHE_PANEL} cachePanel Önbellek yönetim paneli.
99
+ * @property {typeof DEFAULT_ADMIN} admin Framework yönetim paneli.
100
100
  * @property {typeof DEFAULT_CLOUDFLARE} cloudflare Cloudflare cache yüzeyi.
101
101
  * @property {Record<string, unknown>} prewarm
102
102
  * @property {{ source: string, test: (pathname: string) => boolean }[]} prewarmPriority
@@ -104,6 +104,7 @@ const CONFIG_FILE = "jskelet.config.mjs";
104
104
  * @property {Record<string, Function>} hooks
105
105
  * @property {string} layout Layout `.ejs` dosyasının mutlak yolu.
106
106
  * @property {string[] | null} routes Açık route modülü listesi.
107
+ * @property {boolean} trailingSlash URL'ler `/` ile bitsin mi (Next `trailingSlash`).
107
108
  * @property {{ extensions: Set<string>, prefixes: string[] }} static
108
109
  * @property {string[]} devGateBypass
109
110
  * @property {string[]} preconnect
@@ -301,46 +302,58 @@ function normalizeUpstream(raw) {
301
302
  }
302
303
 
303
304
  /**
304
- * Önbellek panelinin bölümü.
305
- *
306
- * `enabled` yalnızca açıkça `true` verildiğinde ya da `JSKELET_CACHE_PANEL`
307
- * ortam değişkeni ayarlandığında açılır: paneli yanlışlıkla açmanın bedeli,
308
- * önbelleği boşaltabilen bir ucu internete koymak.
305
+ * Yönetim paneli. `enabled` yalnızca açıkça `true` verildiğinde ya da
306
+ * `JSKELET_ADMIN` ortam değişkeni ayarlandığında açılır: paneli yanlışlıkla
307
+ * açmanın bedeli, önbelleği boşaltabilen bir ucu internete koymak.
309
308
  *
310
309
  * Ortam değişkeni config'in **üstünde** duruyor, çünkü paneli genelde bir
311
310
  * arıza sırasında tek seferlik açmak isteniyor ve o an config dosyasını
312
- * değiştirip yeniden dağıtmak istenmiyor. `JSKELET_CACHE_PANEL=0` aynı
313
- * mantıkla config'te açık olan paneli kapatır.
311
+ * değiştirip yeniden dağıtmak istenmiyor. `JSKELET_ADMIN=0` aynı mantıkla
312
+ * config'te açık olan paneli kapatır.
314
313
  *
315
314
  * @param {unknown} raw
316
- * @returns {typeof DEFAULT_CACHE_PANEL}
315
+ * @returns {typeof DEFAULT_ADMIN}
317
316
  */
318
- function normalizeCachePanel(raw) {
317
+ function normalizeAdmin(raw) {
319
318
  const source = /** @type {Record<string, any>} */ (raw ?? {});
320
- const env = process.env.JSKELET_CACHE_PANEL;
319
+ const env = process.env.JSKELET_ADMIN;
321
320
 
322
321
  const basePath =
323
322
  typeof source.basePath === "string" && source.basePath.startsWith("/")
324
323
  ? source.basePath.replace(/\/+$/, "")
325
- : DEFAULT_CACHE_PANEL.basePath;
324
+ : DEFAULT_ADMIN.basePath;
326
325
 
327
326
  /** @param {string} key @param {number} min */
328
327
  const positive = (key, min) => {
329
328
  const value = Number(source[key]);
330
329
  return Number.isFinite(value) && value >= min
331
330
  ? value
332
- : /** @type {any} */ (DEFAULT_CACHE_PANEL)[key];
331
+ : /** @type {any} */ (DEFAULT_ADMIN)[key];
333
332
  };
334
333
 
334
+ const allowIps = Array.isArray(source.allowIps)
335
+ ? source.allowIps
336
+ .filter((entry) => typeof entry === "string" && entry.trim())
337
+ .map((entry) => entry.trim())
338
+ : [...DEFAULT_ADMIN.allowIps];
339
+
340
+ const logSize = Number(source.logSize);
341
+
335
342
  return {
336
343
  enabled:
337
344
  env === undefined
338
345
  ? source.enabled === true
339
346
  : env !== "0" && env !== "false" && env !== "",
340
- basePath: basePath || DEFAULT_CACHE_PANEL.basePath,
347
+ basePath: basePath || DEFAULT_ADMIN.basePath,
348
+ allowIps,
349
+ blockBots: source.blockBots !== false,
341
350
  banAttempts: Math.floor(positive("banAttempts", 1)),
342
351
  banHours: positive("banHours", 0),
343
352
  sessionHours: positive("sessionHours", 0),
353
+ logSize:
354
+ Number.isFinite(logSize) && logSize >= 50
355
+ ? Math.min(5000, Math.floor(logSize))
356
+ : DEFAULT_ADMIN.logSize,
344
357
  };
345
358
  }
346
359
 
@@ -423,7 +436,6 @@ function normalizeQueryRules(raw) {
423
436
  * transientRetry: { attempts: number, delayMs: number },
424
437
  * redis: RedisConfig,
425
438
  * upstream: typeof DEFAULT_UPSTREAM_LIMIT,
426
- * cachePanel: typeof DEFAULT_CACHE_PANEL,
427
439
  * cloudflare: typeof DEFAULT_CLOUDFLARE,
428
440
  * prewarm: Record<string, unknown>,
429
441
  * prewarmPriority: ResolvedConfig["prewarmPriority"] }}
@@ -464,7 +476,6 @@ function normalizeCache(raw) {
464
476
  : { ...DEFAULT_TRANSIENT_RETRY, ...(raw?.transientRetry ?? {}) },
465
477
  redis: normalizeRedis(raw?.redis),
466
478
  upstream: normalizeUpstream(raw?.upstream),
467
- cachePanel: normalizeCachePanel(raw?.panel),
468
479
  cloudflare: normalizeCloudflare(raw?.cloudflare),
469
480
  // Desenler derlenmiş hâlde ayrı alanda tutulur: `prewarm` sayısal
470
481
  // ayarların düz torbası olarak kalsın, her turda yeniden derlenmesin.
@@ -669,11 +680,12 @@ export async function loadConfig(options = {}) {
669
680
  }
670
681
  };
671
682
 
672
- const [headers, redirects, rewrites, cache] = await Promise.all([
683
+ const [headers, redirects, rewrites, cache, admin] = await Promise.all([
673
684
  section("headers"),
674
685
  section("redirects"),
675
686
  section("rewrites"),
676
687
  section("cache"),
688
+ section("admin"),
677
689
  ]);
678
690
 
679
691
  const {
@@ -686,7 +698,6 @@ export async function loadConfig(options = {}) {
686
698
  transientRetry,
687
699
  redis,
688
700
  upstream,
689
- cachePanel,
690
701
  cloudflare,
691
702
  prewarm,
692
703
  prewarmPriority,
@@ -710,7 +721,7 @@ export async function loadConfig(options = {}) {
710
721
  transientRetry,
711
722
  redis,
712
723
  upstream,
713
- cachePanel,
724
+ admin: normalizeAdmin(admin),
714
725
  cloudflare,
715
726
  prewarm,
716
727
  prewarmPriority,
@@ -718,6 +729,11 @@ export async function loadConfig(options = {}) {
718
729
  hooks: source.hooks ?? {},
719
730
  layout: resolveLayout(dirs, source.layout),
720
731
  routes: Array.isArray(source.routes) ? source.routes : null,
732
+ // Varsayılan kapalı: açıkken `/hakkinda` → 308 `/hakkinda/` ve kanonik
733
+ // yanıt 200'dir. Kapalıyken slash dayatılmaz — Express'in non-strict
734
+ // eşleşmesi her iki biçimi de 200 ile servis eder (Next'in varsayılan
735
+ // "slash'ı kırp" davranışından bilinçli fark).
736
+ trailingSlash: source.trailingSlash === true,
721
737
  static: {
722
738
  extensions: new Set(source.static?.extensions ?? DEFAULT_STATIC.extensions),
723
739
  prefixes: source.static?.prefixes ?? DEFAULT_STATIC.prefixes,
package/src/log.mjs CHANGED
@@ -253,6 +253,14 @@ export function event({ symbol = symbols.ok, scope, message, note, time = null }
253
253
  `${message}${note ? ` ${c.dim(note)}` : ""}${padding}` +
254
254
  `${c.gray((time == null ? "" : ms(time)).padStart(TIME))}\n`,
255
255
  );
256
+
257
+ emitLog({
258
+ kind: "event",
259
+ scope,
260
+ message,
261
+ note: note ?? null,
262
+ ms: time ?? null,
263
+ });
256
264
  }
257
265
 
258
266
  /**
@@ -271,6 +279,15 @@ export function http(info) {
271
279
  `${tint(String(info.status))} ${c.gray(ms(info.ms).padStart(TIME))}` +
272
280
  `${info.cache === "HIT" ? ` ${c.dim("cached")}` : ""}\n`,
273
281
  );
282
+
283
+ emitLog({
284
+ kind: "http",
285
+ method: info.method,
286
+ url: info.url,
287
+ status: info.status,
288
+ ms: info.ms,
289
+ cache: info.cache ?? null,
290
+ });
274
291
  }
275
292
 
276
293
  /**
@@ -337,6 +354,14 @@ export function errorBox({ title, name, message, lines = [] }) {
337
354
  }
338
355
  line("");
339
356
  write(`${c.red(bottom)}\n\n`);
357
+
358
+ emitLog({
359
+ kind: "error",
360
+ scope: title,
361
+ message: `${name}: ${message}`,
362
+ note: lines.length ? lines.join(" · ") : null,
363
+ ms: null,
364
+ });
340
365
  }
341
366
 
342
367
  /**
@@ -361,3 +386,36 @@ function wrap(text, max) {
361
386
  if (current) out.push(current);
362
387
  return out.length ? out : [""];
363
388
  }
389
+
390
+ /* -------------------------------------------------------------- aboneler */
391
+
392
+ /**
393
+ * Admin paneli gibi dinleyiciler. Abone yokken `emitLog` boş Set üzerinde
394
+ * döner — stdout yoluna maliyet eklemez.
395
+ *
396
+ * @type {Set<(entry: Record<string, unknown>) => void>}
397
+ */
398
+ const listeners = new Set();
399
+
400
+ /**
401
+ * @param {(entry: Record<string, unknown>) => void} listener
402
+ * @returns {() => void} Aboneliği iptal eden fonksiyon.
403
+ */
404
+ export function subscribe(listener) {
405
+ listeners.add(listener);
406
+ return () => listeners.delete(listener);
407
+ }
408
+
409
+ /**
410
+ * @param {Record<string, unknown>} entry
411
+ */
412
+ function emitLog(entry) {
413
+ if (!listeners.size) return;
414
+ for (const listener of listeners) {
415
+ try {
416
+ listener(entry);
417
+ } catch {
418
+ // Dinleyici paneli düşürmesin.
419
+ }
420
+ }
421
+ }