jskelet 0.4.7 → 0.5.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.
@@ -17,7 +17,7 @@ const BASE = "/__jskelet/dev";
17
17
  const POLL_MS = 2000;
18
18
  const MAX_ERRORS = 100;
19
19
 
20
- /** @type {{ id: number, level: string, message: string, stack: string | null, source: string, at: number }[]} */
20
+ /** @type {{ id: number, level: string, message: string, stack: string | null, source: string, page?: string | null, island?: string | null, url?: string | null, details?: unknown, at: number }[]} */
21
21
  const clientErrors = [];
22
22
  let nextId = 1;
23
23
 
@@ -109,7 +109,14 @@ function loadState() {
109
109
  /**
110
110
  * @param {string} level
111
111
  * @param {string} message
112
- * @param {{ stack?: string | null, source?: string }} [extra]
112
+ * @param {{
113
+ * stack?: string | null,
114
+ * source?: string,
115
+ * page?: string | null,
116
+ * island?: string | null,
117
+ * url?: string | null,
118
+ * details?: unknown,
119
+ * }} [extra]
113
120
  */
114
121
  function pushError(level, message, extra = {}) {
115
122
  clientErrors.unshift({
@@ -118,6 +125,10 @@ function pushError(level, message, extra = {}) {
118
125
  message,
119
126
  stack: extra.stack ?? null,
120
127
  source: extra.source ?? "client",
128
+ page: extra.page ?? location.pathname,
129
+ island: extra.island ?? null,
130
+ url: extra.url ?? null,
131
+ details: extra.details ?? null,
121
132
  at: Date.now(),
122
133
  });
123
134
  if (clientErrors.length > MAX_ERRORS) clientErrors.length = MAX_ERRORS;
@@ -318,6 +329,8 @@ const clientApi = [];
318
329
  /**
319
330
  * Tarayıcıdaki veri çağrıları. Panelin kendi uçları ve statik varlıklar
320
331
  * sayılmaz; amaç sayfanın hangi API'lere kaç ms harcadığını görmek.
332
+ * 4xx/5xx ve ağ hataları Errors sekmesine de düşer — hangi sayfa / island
333
+ * bağlamı mümkün olduğunca doldurulur.
321
334
  */
322
335
  function captureFetch() {
323
336
  const original = window.fetch.bind(window);
@@ -326,13 +339,34 @@ function captureFetch() {
326
339
  const url = typeof input === "string" ? input : (input?.url ?? String(input));
327
340
  if (url.includes(BASE)) return original(input, init);
328
341
 
342
+ const method = String(init?.method ?? "GET").toUpperCase();
329
343
  const started = performance.now();
330
344
  try {
331
345
  const response = await original(input, init);
332
346
  record(url, performance.now() - started, response.status, response);
347
+ if (!response.ok) {
348
+ void reportFailedFetch({
349
+ url,
350
+ method,
351
+ status: response.status,
352
+ response,
353
+ });
354
+ }
333
355
  return response;
334
356
  } catch (error) {
335
357
  record(url, performance.now() - started, 0, null);
358
+ pushError(
359
+ "error",
360
+ `${method} ${shortUrl(url)} → network error`,
361
+ {
362
+ source: "fetch",
363
+ url,
364
+ page: location.pathname + location.search,
365
+ island: inferIsland(),
366
+ stack: error instanceof Error ? error.stack : null,
367
+ details: error instanceof Error ? error.message : String(error),
368
+ },
369
+ );
336
370
  throw error;
337
371
  }
338
372
  };
@@ -355,6 +389,99 @@ function captureFetch() {
355
389
  }
356
390
  }
357
391
 
392
+ /**
393
+ * Başarısız bir tarayıcı fetch'ini Errors listesine yazar. Gövdeyi klonlayıp
394
+ * okur; asıl Response tüketilmez.
395
+ *
396
+ * @param {{ url: string, method: string, status: number, response: Response }} call
397
+ */
398
+ async function reportFailedFetch(call) {
399
+ const details = await readResponseDetails(call.response);
400
+ const summary = detailSummary(details);
401
+ pushError(
402
+ "error",
403
+ `${call.method} ${shortUrl(call.url)} → ${call.status}${
404
+ summary ? `: ${summary}` : ""
405
+ }`,
406
+ {
407
+ source: "fetch",
408
+ url: call.url,
409
+ page: location.pathname + location.search,
410
+ island: inferIsland(),
411
+ details,
412
+ },
413
+ );
414
+ }
415
+
416
+ /**
417
+ * @param {Response} response
418
+ * @returns {Promise<unknown>}
419
+ */
420
+ async function readResponseDetails(response) {
421
+ try {
422
+ const text = (await response.clone().text()).slice(0, 4000);
423
+ if (!text) return null;
424
+ try {
425
+ return JSON.parse(text);
426
+ } catch {
427
+ return text;
428
+ }
429
+ } catch {
430
+ return null;
431
+ }
432
+ }
433
+
434
+ /**
435
+ * @param {unknown} details
436
+ * @returns {string | null}
437
+ */
438
+ function detailSummary(details) {
439
+ if (details == null) return null;
440
+ if (typeof details === "string") return details.slice(0, 200);
441
+ if (typeof details !== "object") return String(details);
442
+ const record = /** @type {Record<string, unknown>} */ (details);
443
+ for (const key of ["details", "detail", "message", "error", "title"]) {
444
+ const value = record[key];
445
+ if (typeof value === "string" && value.trim()) return value.slice(0, 200);
446
+ if (value && typeof value === "object") {
447
+ const nested = detailSummary(value);
448
+ if (nested) return nested;
449
+ }
450
+ }
451
+ try {
452
+ return JSON.stringify(details).slice(0, 200);
453
+ } catch {
454
+ return null;
455
+ }
456
+ }
457
+
458
+ /** @param {string} url */
459
+ function shortUrl(url) {
460
+ try {
461
+ const parsed = new URL(url, location.origin);
462
+ return parsed.origin === location.origin
463
+ ? parsed.pathname + parsed.search
464
+ : parsed.host + parsed.pathname + parsed.search;
465
+ } catch {
466
+ return url;
467
+ }
468
+ }
469
+
470
+ /**
471
+ * Çağrı yığınında `islands/ad` izi varsa ya da bir island mount edilirken
472
+ * yakalanmışsa ada bağlar.
473
+ *
474
+ * @returns {string | null}
475
+ */
476
+ function inferIsland() {
477
+ const mounting = globalThis.__JSKELET_MOUNTING_ISLAND;
478
+ if (typeof mounting === "string" && mounting) return mounting;
479
+
480
+ const stack = new Error().stack ?? "";
481
+ const match = stack.match(/islands[/\\]([A-Za-z0-9_-]+)/);
482
+ return match?.[1] ?? null;
483
+ }
484
+
358
485
  /**
359
486
  * Sayfa ölçümlerini rapora gönderir. Sekme kapanırken de gitmesi gerektiği
360
487
  * için mümkünse `sendBeacon` kullanılır.
@@ -503,7 +630,7 @@ function handleServerMessage(payload) {
503
630
  }
504
631
 
505
632
  if (payload.type === "css") {
506
- swapStylesheet(payload.href);
633
+ swapStylesheet(payload.href, payload.name);
507
634
  return;
508
635
  }
509
636
 
@@ -537,13 +664,18 @@ function startFallback() {
537
664
 
538
665
  /**
539
666
  * Yeni sheet yüklenmeden eskisi kaldırılmaz; böylece stilsiz bir kare oluşmaz.
667
+ * `name` varsa `data-jskelet-css` ile doğru link seçilir (sayfa sheet'leri).
668
+ *
540
669
  * @param {string} href
670
+ * @param {string} [name] Manifest anahtarı (`app.css`, `home.css`, …)
541
671
  */
542
- function swapStylesheet(href) {
543
- const current = document.querySelector('link[rel="stylesheet"]');
672
+ function swapStylesheet(href, name) {
673
+ const current = name
674
+ ? document.querySelector(`link[rel="stylesheet"][data-jskelet-css="${CSS.escape(name)}"]`)
675
+ : document.querySelector('link[rel="stylesheet"]');
544
676
  if (!current || current.getAttribute("href") === href) return;
545
677
 
546
- const next = current.cloneNode();
678
+ const next = /** @type {HTMLLinkElement} */ (current.cloneNode());
547
679
  next.href = href;
548
680
  next.addEventListener("load", () => current.remove(), { once: true });
549
681
  current.after(next);
@@ -847,7 +979,11 @@ h4:first-child { margin-top: 0; }
847
979
  .item.warn { border-inline-start-color: var(--mid); }
848
980
  .item.selected { border-color: rgba(255,255,255,.22); background: var(--surface-hover); }
849
981
  .item .meta { color: var(--muted); font-size: 10.5px; display: flex; align-items: center; gap: 8px; margin-bottom: 3px; }
982
+ .item .ctx-row { flex-wrap: wrap; gap: 6px; margin-bottom: 6px; }
983
+ .item .ctx { font-family: ui-monospace, SFMono-Regular, monospace; font-size: 10.5px; color: #cfd6de; }
984
+ .item .ctx.muted { color: var(--muted); max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
850
985
  .tag { border: 1px solid var(--line); border-radius: 999px; padding: 0 7px; text-transform: uppercase; letter-spacing: .05em; font-weight: 600; font-size: 9.5px; }
986
+ .tag.island { text-transform: none; letter-spacing: 0; color: #c4b5fd; border-color: rgba(196,181,253,.35); }
851
987
  .msg { word-break: break-word; color: var(--text); }
852
988
  .item pre { margin: 8px 0 0; padding: 8px 10px; background: rgba(0,0,0,.4); border-radius: 8px; white-space: pre-wrap; word-break: break-word; color: #b8c0cb; font-size: 11px; font-family: ui-monospace, SFMono-Regular, monospace; max-height: 150px; overflow: auto; }
853
989
  .item .detail-text { margin: 8px 0 0; color: #b8c0cb; font-size: 12px; line-height: 1.55; }
@@ -1101,29 +1237,80 @@ const clipboard = new Map();
1101
1237
  let copiedKey = null;
1102
1238
 
1103
1239
  /**
1104
- * @param {{ level: string, message: string, stack?: string | null, source?: string, at: number }} item
1240
+ * @param {{
1241
+ * level: string,
1242
+ * message: string,
1243
+ * stack?: string | null,
1244
+ * source?: string,
1245
+ * page?: string | null,
1246
+ * island?: string | null,
1247
+ * url?: string | null,
1248
+ * details?: unknown,
1249
+ * at: number,
1250
+ * }} item
1105
1251
  * @returns {string}
1106
1252
  */
1107
1253
  function asText(item) {
1108
1254
  const time = new Date(item.at).toISOString();
1255
+ const details =
1256
+ item.details == null
1257
+ ? ""
1258
+ : typeof item.details === "string"
1259
+ ? item.details
1260
+ : safeJson(item.details);
1109
1261
  return [
1110
1262
  `[${item.level}] ${item.source ?? "server"} · ${time}`,
1263
+ item.page ? `page: ${item.page}` : "",
1264
+ item.island ? `island: ${item.island}` : "",
1265
+ item.url ? `url: ${item.url}` : "",
1111
1266
  item.message,
1267
+ details,
1112
1268
  item.stack ?? "",
1113
1269
  ]
1114
1270
  .filter(Boolean)
1115
1271
  .join("\n");
1116
1272
  }
1117
1273
 
1118
- /** @param {{ id?: number, level: string, message: string, stack?: string | null, source?: string, at: number }[]} list */
1274
+ /**
1275
+ * @param {unknown} value
1276
+ * @returns {string}
1277
+ */
1278
+ function formatDetails(value) {
1279
+ if (value == null) return "";
1280
+ if (typeof value === "string") return value;
1281
+ try {
1282
+ return JSON.stringify(value, null, 2);
1283
+ } catch {
1284
+ return safeJson(value);
1285
+ }
1286
+ }
1287
+
1288
+ /** @param {{ id?: number, level: string, message: string, stack?: string | null, source?: string, page?: string | null, island?: string | null, url?: string | null, details?: unknown, at: number }[]} list */
1119
1289
  function errorList(list, scope) {
1120
1290
  if (!list.length) return `<div class="empty">No records.</div>`;
1121
1291
  return list
1122
1292
  .map((item) => {
1123
1293
  const key = `${scope}:${item.id ?? item.at}`;
1124
1294
  const shown = expanded.has(key);
1295
+ const detailsKey = `${key}:details`;
1296
+ const detailsShown = expanded.has(detailsKey);
1297
+ const detailsText = formatDetails(item.details);
1125
1298
  clipboard.set(key, asText(item));
1126
1299
 
1300
+ const context = [
1301
+ item.page
1302
+ ? `<span class="ctx" title="Page">${pathLink(item.page)}</span>`
1303
+ : "",
1304
+ item.island
1305
+ ? `<span class="tag island" title="Island">${escapeHtml(item.island)}</span>`
1306
+ : "",
1307
+ item.url && item.url !== item.page
1308
+ ? `<span class="ctx muted" title="Request">${escapeHtml(shortUrl(item.url))}</span>`
1309
+ : "",
1310
+ ]
1311
+ .filter(Boolean)
1312
+ .join("");
1313
+
1127
1314
  return `<div class="item ${item.level === "warn" ? "warn" : ""}">
1128
1315
  <div class="meta">
1129
1316
  <span class="tag">${escapeHtml(item.source ?? "server")}</span>
@@ -1131,7 +1318,14 @@ function errorList(list, scope) {
1131
1318
  <span class="spacer"></span>
1132
1319
  ${copyButton(key)}
1133
1320
  </div>
1321
+ ${context ? `<div class="meta ctx-row">${context}</div>` : ""}
1134
1322
  <div class="msg">${escapeHtml(item.message)}</div>
1323
+ ${
1324
+ detailsText
1325
+ ? `<button class="link" data-action="stack" data-key="${escapeHtml(detailsKey)}">${detailsShown ? "▾ hide details" : "▸ show details"}</button>
1326
+ ${detailsShown ? `<pre>${escapeHtml(detailsText)}</pre>` : ""}`
1327
+ : ""
1328
+ }
1135
1329
  ${
1136
1330
  item.stack
1137
1331
  ? `<button class="link" data-action="stack" data-key="${escapeHtml(key)}">${shown ? "▾ hide stack" : "▸ show stack"}</button>
@@ -1187,7 +1381,7 @@ function errorsTab() {
1187
1381
  ${lede(
1188
1382
  errorCount ? "bad" : total ? "mid" : "good",
1189
1383
  errorCount
1190
- ? `<strong>${errorCount} errors</strong> captured${total - errorCount ? `, plus ${total - errorCount} warnings` : ""}. Newest first; expand the stack trace and copy a record with one click.`
1384
+ ? `<strong>${errorCount} errors</strong> captured${total - errorCount ? `, plus ${total - errorCount} warnings` : ""}. Newest first; expand details or the stack, and copy a record with one click. Failed fetch calls include the page and API path.`
1191
1385
  : total
1192
1386
  ? `No errors, <strong>${total} warnings</strong>. Warnings usually come from missing data or islands that were never ported.`
1193
1387
  : "No browser or server side errors were seen in this session.",
@@ -556,6 +556,7 @@ function apiTab() {
556
556
  "api-calls",
557
557
  [
558
558
  { key: "url", label: "URL" },
559
+ { key: "page", label: "Page", render: (row) => (row.page ? link(row.page) : "—") },
559
560
  { key: "method", label: "Method" },
560
561
  { key: "status", label: "Status", render: (row) => `<span class="status ${row.error ? "bad" : ""}">${row.status || "—"}</span>` },
561
562
  { key: "ms", label: "Time", value: (row) => row.ms, render: (row) => `<span class="${tone(row.ms, [200, 800])}">${ms(row.ms)}</span>` },
@@ -589,6 +590,25 @@ function errorsTab() {
589
590
  "errors",
590
591
  [
591
592
  { key: "message", label: "Message" },
593
+ { key: "page", label: "Page", render: (row) => (row.page ? link(row.page) : "—") },
594
+ {
595
+ key: "details",
596
+ label: "Details",
597
+ render: (row) => {
598
+ if (row.details == null) return "—";
599
+ const text =
600
+ typeof row.details === "string"
601
+ ? row.details
602
+ : (() => {
603
+ try {
604
+ return JSON.stringify(row.details);
605
+ } catch {
606
+ return String(row.details);
607
+ }
608
+ })();
609
+ return `<span class="hint" title="${escapeHtml(text)}">${escapeHtml(text.slice(0, 120))}${text.length > 120 ? "…" : ""}</span>`;
610
+ },
611
+ },
592
612
  { key: "level", label: "Level", render: (row) => `<span class="tag">${escapeHtml(row.level)}</span>` },
593
613
  { key: "at", label: "Time", value: (row) => row.at, render: (row) => new Date(row.at).toLocaleTimeString() },
594
614
  ],
@@ -110,6 +110,12 @@ async function mountIsland(element) {
110
110
 
111
111
  markMounted(element);
112
112
 
113
+ // Dev overlay başarısız fetch'i hangi island'dan geldiğine bağlayabilsin.
114
+ // Mount bittikten sonra (event handler'lar) bu işaret kalkar; o durumda
115
+ // yığın izine düşülür.
116
+ const previous = globalThis.__JSKELET_MOUNTING_ISLAND;
117
+ globalThis.__JSKELET_MOUNTING_ISLAND = name;
118
+
113
119
  try {
114
120
  const module = await loader();
115
121
  const cleanup = module.mount(element, readProps(element));
@@ -130,6 +136,8 @@ async function mountIsland(element) {
130
136
  element.dataset.islandReady = "true";
131
137
  } catch (error) {
132
138
  console.error(`[island] ${name} failed to load`, error);
139
+ } finally {
140
+ globalThis.__JSKELET_MOUNTING_ISLAND = previous;
133
141
  }
134
142
  }
135
143
 
@@ -25,13 +25,15 @@ import { AsyncLocalStorage } from "node:async_hooks";
25
25
  * @property {string[]} taintReasons Hangi erişimler işaretledi (teşhis için).
26
26
  * @property {string | null} csrfToken
27
27
  * @property {import('express').Response | null} res
28
+ * @property {string | null} pathname Devtools: bu isteğin sayfa yolu (SSR
29
+ * sırasında upstream hatasına "hangi sayfa" bağlamak için).
28
30
  */
29
31
 
30
32
  /** @type {AsyncLocalStorage<RequestContext>} */
31
33
  const storage = new AsyncLocalStorage();
32
34
 
33
35
  /**
34
- * @param {{ private?: boolean, res?: import('express').Response }} [initial]
36
+ * @param {{ private?: boolean, res?: import('express').Response, pathname?: string | null }} [initial]
35
37
  * @returns {RequestContext}
36
38
  */
37
39
  export function createRequestContext(initial = {}) {
@@ -41,6 +43,7 @@ export function createRequestContext(initial = {}) {
41
43
  taintReasons: [],
42
44
  csrfToken: null,
43
45
  res: initial.res ?? null,
46
+ pathname: initial.pathname ?? null,
44
47
  };
45
48
  }
46
49
 
@@ -13,6 +13,7 @@ import { createHash } from "node:crypto";
13
13
  import express from "express";
14
14
  import * as log from "../../log.mjs";
15
15
  import { FRAMEWORK_ROOT, getConfig } from "../../config/index.js";
16
+ import { getRequestContext } from "../../http/request-context.js";
16
17
  import { prewarm, prewarmProgress } from "../prewarm.js";
17
18
  import { clearHtmlCache } from "../html-cache.js";
18
19
  import {
@@ -55,7 +56,21 @@ const STATE_FILE = path.join(
55
56
  /** @type {{ id: number, method: string, url: string, status: number, ms: number, cache: string | null, at: number }[]} */
56
57
  let requests = [];
57
58
 
58
- /** @type {{ id: number, level: string, message: string, stack: string | null, url: string | null, at: number }[]} */
59
+ /**
60
+ * @typedef {{
61
+ * id: number,
62
+ * level: string,
63
+ * message: string,
64
+ * stack: string | null,
65
+ * url: string | null,
66
+ * page: string | null,
67
+ * island: string | null,
68
+ * details: unknown,
69
+ * at: number,
70
+ * }} ServerError
71
+ */
72
+
73
+ /** @type {ServerError[]} */
59
74
  let errors = [];
60
75
 
61
76
  let nextId = 1;
@@ -103,7 +118,13 @@ function trim(list) {
103
118
  /**
104
119
  * @param {string} level
105
120
  * @param {string} message
106
- * @param {{ stack?: string | null, url?: string | null }} [extra]
121
+ * @param {{
122
+ * stack?: string | null,
123
+ * url?: string | null,
124
+ * page?: string | null,
125
+ * island?: string | null,
126
+ * details?: unknown,
127
+ * }} [extra]
107
128
  */
108
129
  export function recordServerError(level, message, extra = {}) {
109
130
  errors.push({
@@ -112,6 +133,9 @@ export function recordServerError(level, message, extra = {}) {
112
133
  message,
113
134
  stack: extra.stack ?? null,
114
135
  url: extra.url ?? null,
136
+ page: extra.page ?? null,
137
+ island: extra.island ?? null,
138
+ details: extra.details ?? null,
115
139
  at: Date.now(),
116
140
  });
117
141
  trim(errors);
@@ -121,21 +145,49 @@ export function recordServerError(level, message, extra = {}) {
121
145
 
122
146
  /**
123
147
  * `console.error` / `console.warn` çıktısını da overlay'e taşır: sunucudaki
124
- * uyarılar terminalde kaybolmasın.
148
+ * uyarılar terminalde kaybolmasın. Render bağlamındaysa sayfa yolu da yazılır.
125
149
  */
126
150
  function patchConsole() {
127
151
  for (const level of /** @type {const} */ (["error", "warn"])) {
128
152
  const original = console[level].bind(console);
129
153
  console[level] = (...args) => {
130
154
  const error = args.find((arg) => arg instanceof Error);
155
+ const page = currentPage();
131
156
  recordServerError(level, args.map(format).join(" "), {
132
157
  stack: error?.stack ?? null,
158
+ page,
159
+ url: page,
160
+ details: extractDetails(args),
133
161
  });
134
162
  original(...args);
135
163
  };
136
164
  }
137
165
  }
138
166
 
167
+ /** @returns {string | null} */
168
+ function currentPage() {
169
+ return getRequestContext()?.pathname ?? null;
170
+ }
171
+
172
+ /**
173
+ * console argümanlarından yapılandırılmış bir `details` alanı ayıklar.
174
+ * Uygulama `console.error("msg", { details: {...} })` yazdığında overlay
175
+ * nesneyi `[object Object]` yerine açılabilir JSON olarak görsün.
176
+ *
177
+ * @param {unknown[]} args
178
+ * @returns {unknown}
179
+ */
180
+ function extractDetails(args) {
181
+ for (const arg of args) {
182
+ if (!arg || typeof arg !== "object" || arg instanceof Error) continue;
183
+ const record = /** @type {Record<string, unknown>} */ (arg);
184
+ if ("details" in record) return record.details;
185
+ if ("detail" in record) return record.detail;
186
+ return record;
187
+ }
188
+ return null;
189
+ }
190
+
139
191
  /**
140
192
  * @param {unknown} value
141
193
  * @returns {string}
@@ -144,12 +196,73 @@ function format(value) {
144
196
  if (typeof value === "string") return value;
145
197
  if (value instanceof Error) return `${value.name}: ${value.message}`;
146
198
  try {
147
- return JSON.stringify(value);
199
+ return JSON.stringify(value, (_key, nested) => {
200
+ // Döngüsel referanslarda stringify zaten fırlar; burada yalnızca
201
+ // Error örneklerini okunabilir kılmak yeterli.
202
+ if (nested instanceof Error) {
203
+ return { name: nested.name, message: nested.message };
204
+ }
205
+ return nested;
206
+ });
148
207
  } catch {
149
208
  return String(value);
150
209
  }
151
210
  }
152
211
 
212
+ /**
213
+ * Upstream `fetch` başarısızlığını overlay hata listesine yazar.
214
+ *
215
+ * @param {{
216
+ * url: string,
217
+ * method: string,
218
+ * status: number,
219
+ * ms: number,
220
+ * bytes: number,
221
+ * error: string | null,
222
+ * page: string | null,
223
+ * details: unknown,
224
+ * }} call
225
+ */
226
+ function recordApiFailure(call) {
227
+ // Aynı SSR turunda hem fetch sarmalayıcısı hem uygulama logger'ı aynı
228
+ // hatayı basabiliyor; kısa pencerede tekilleştir.
229
+ const last = errors.at(-1);
230
+ if (
231
+ last &&
232
+ last.url === call.url &&
233
+ last.page === call.page &&
234
+ Date.now() - last.at < 2000
235
+ ) {
236
+ // İlk kayıtta details yoksa sonrakinin gövdesini birleştir.
237
+ if (last.details == null && call.details != null) {
238
+ last.details = call.details;
239
+ if (call.error && !last.message.includes(call.error)) {
240
+ last.message = `${call.method} ${shortApiPath(call.url)} → ${call.error}`;
241
+ }
242
+ persist();
243
+ pushStats();
244
+ }
245
+ return;
246
+ }
247
+
248
+ recordServerError("error", `${call.method} ${shortApiPath(call.url)} → ${call.error ?? call.status}`, {
249
+ url: call.url,
250
+ page: call.page,
251
+ details: call.details,
252
+ stack: null,
253
+ });
254
+ }
255
+
256
+ /** @param {string} url */
257
+ function shortApiPath(url) {
258
+ try {
259
+ const parsed = new URL(url);
260
+ return parsed.pathname + parsed.search;
261
+ } catch {
262
+ return url;
263
+ }
264
+ }
265
+
153
266
  /** Her HTML isteğinin süresini ve cache durumunu kaydeder. */
154
267
  function timing() {
155
268
  /** @type {import('express').RequestHandler} */
@@ -313,8 +426,12 @@ function watchManifest() {
313
426
  // sayfa silinmiş dosyayı istemeye devam eder.
314
427
  clearHtmlCache();
315
428
 
316
- if (changed.length === 1 && changed[0] === "app.css") {
317
- broadcast({ type: "css", href: next["app.css"] });
429
+ // Yalnızca stylesheet anahtarları değiştiyse hot-swap; JS/sprite için
430
+ // tam yenileme gerekir. Birden fazla sayfa sheet'i de desteklenir.
431
+ if (changed.every((key) => key.endsWith(".css"))) {
432
+ for (const name of changed) {
433
+ broadcast({ type: "css", name, href: next[name] });
434
+ }
318
435
  return;
319
436
  }
320
437
 
@@ -448,7 +565,7 @@ export function mountDevtools(app) {
448
565
 
449
566
  restore();
450
567
  patchConsole();
451
- trackServerFetch();
568
+ trackServerFetch({ onFailure: recordApiFailure });
452
569
  watchManifest();
453
570
  startVersionCheck();
454
571
  startHeartbeat();