jskelet 0.4.7 → 0.4.8

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/CHANGELOG.md CHANGED
@@ -10,6 +10,11 @@ one is listed under a **Breaking** heading.
10
10
 
11
11
  ### Added
12
12
 
13
+ - Dev overlay Errors tab now lists failed SSR and browser `fetch` calls with
14
+ page path, API URL, optional island name, and expandable response-body
15
+ details (JSON instead of `[object Object]`). Server `console.error` /
16
+ `console.warn` records also carry the current page when they fire during
17
+ render.
13
18
  - Visit-driven HTML prewarm (`cache().prewarm.onVisit`): after each public
14
19
  cacheable page response, same-origin links in the HTML are warmed in the
15
20
  background (document order, `perPage` cap). Mutually exclusive with classic
@@ -177,9 +177,13 @@ Tüm arayüz shadow DOM içinde durur, sayfanın CSS'i ile karışmaz.
177
177
  Gösterdikleri:
178
178
 
179
179
  - **Hatalar:** tarayıcı tarafındaki JS hataları, kaynak yükleme hataları
180
- (`img`/`script`/`link`), ve sunucudaki `console.error` / `console.warn`
181
- çıktıları. Sunucu tarafında `console` sarılır, böylece uyarılar terminalde
182
- kaybolmaz.
180
+ (`img`/`script`/`link`), sunucudaki `console.error` / `console.warn`
181
+ çıktıları, ve SSR / tarayıcı `fetch` çağrılarının 4xx/5xx ya da ağ
182
+ başarısızlıkları. Her kayıt mümkün olduğunca **sayfa yolu**, **API URL** ve
183
+ (istemcide) **island adı** taşır; yanıt gövdesi **show details** ile açılır —
184
+ `[object Object]` yerine JSON. Sunucu tarafında `console` sarılır, böylece
185
+ uyarılar terminalde kaybolmaz; upstream hataları uygulamanın kendi logger'ı
186
+ stderr'e yazsa bile overlay'e düşer.
183
187
  - **SEO:** açık sayfanın istemci tarafı taraması — title ve meta description
184
188
  uzunluğu, `html lang`, viewport, canonical, robots/`noindex`, Open Graph ve
185
189
  Twitter etiketleri, H1/outline, görsel `alt`, boş linkler ve JSON-LD parse
@@ -244,9 +248,11 @@ http://localhost:3000/__jskelet/dev/report
244
248
  gezilmemiş ama ısıtılmış sayfalar da listelenir: SSR tarafı bilinir, istemci
245
249
  ölçümleri boş kalır.
246
250
  - **Sunucu API çağrıları:** SSR sırasında yapılan dış `fetch` çağrıları — URL,
247
- host, metot, durum, süre, bayt. `globalThis.fetch` yalnızca development'ta
251
+ host, metot, durum, süre, bayt, hangi sayfa render edilirken yapıldığı ve
252
+ başarısız cevaplarda gövde özeti. `globalThis.fetch` yalnızca development'ta
248
253
  sarılır; üretim yolu dokunulmaz kalır. Kendi sunucumuza yapılan istekler
249
- (ısıtma, sağlık kontrolü) API sayılmaz.
254
+ (ısıtma, sağlık kontrolü) API sayılmaz. Başarısız çağrılar overlay Errors
255
+ sekmesine de düşer.
250
256
  - **Build çıktısı:** manifest'teki her varlığın ham/gzip/brotli boyutu, ve
251
257
  esbuild metafile'ından chunk analizi — her çıktının boyutu, hangi kaynaklardan
252
258
  oluştuğu, hangi chunk'ları import ettiği. Kaynaklar okunur gruplara indirgenir
@@ -181,9 +181,13 @@ the page's CSS.
181
181
  What it shows:
182
182
 
183
183
  - **Errors:** browser-side JS errors, resource loading errors
184
- (`img`/`script`/`link`), and the server's `console.error` / `console.warn`
185
- output. On the server side `console` is wrapped so warnings do not get lost in
186
- the terminal.
184
+ (`img`/`script`/`link`), the server's `console.error` / `console.warn`
185
+ output, and failed SSR / browser `fetch` calls (4xx/5xx or network). Each
186
+ record carries a **page path**, **API URL**, and (on the client) an **island
187
+ name** when known; the response body opens under **show details** as JSON
188
+ instead of `[object Object]`. On the server side `console` is wrapped so
189
+ warnings do not get lost in the terminal; upstream failures still land in the
190
+ overlay even when the app's own logger writes them only to stderr.
187
191
  - **SEO:** a client-side scan of the current page — title and meta description
188
192
  length, `html lang`, viewport, canonical, robots/`noindex`, Open Graph and
189
193
  Twitter tags, H1/outline, image `alt`, empty links, and JSON-LD parse errors.
@@ -248,9 +252,10 @@ Its contents:
248
252
  status of the SSR output. Pages that were never visited but were warmed are
249
253
  listed too: the SSR side is known, the client measurements stay empty.
250
254
  - **Server API calls:** outbound `fetch` calls made during SSR — URL, host,
251
- method, status, duration, bytes. `globalThis.fetch` is only wrapped in
252
- development; the production path is left untouched. Requests to our own server
253
- (warming, health check) do not count as API calls.
255
+ method, status, duration, bytes, which page was rendering, and a body summary
256
+ on failures. `globalThis.fetch` is only wrapped in development; the production
257
+ path is left untouched. Requests to our own server (warming, health check) do
258
+ not count as API calls. Failures also appear on the overlay Errors tab.
254
259
  - **Build output:** the raw/gzip/brotli size of every asset in the manifest, and
255
260
  chunk analysis from esbuild's metafile — the size of each output, which
256
261
  sources it is made of, which chunks it imports. Sources are reduced to
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jskelet",
3
- "version": "0.4.7",
3
+ "version": "0.4.8",
4
4
  "description": "A framework that feels like no framework: Express 5 + build-time .jsk (or EJS) SSR, vanilla JS islands, Tailwind v4 and an in-process HTML TTL cache.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -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.
@@ -847,7 +974,11 @@ h4:first-child { margin-top: 0; }
847
974
  .item.warn { border-inline-start-color: var(--mid); }
848
975
  .item.selected { border-color: rgba(255,255,255,.22); background: var(--surface-hover); }
849
976
  .item .meta { color: var(--muted); font-size: 10.5px; display: flex; align-items: center; gap: 8px; margin-bottom: 3px; }
977
+ .item .ctx-row { flex-wrap: wrap; gap: 6px; margin-bottom: 6px; }
978
+ .item .ctx { font-family: ui-monospace, SFMono-Regular, monospace; font-size: 10.5px; color: #cfd6de; }
979
+ .item .ctx.muted { color: var(--muted); max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
850
980
  .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; }
981
+ .tag.island { text-transform: none; letter-spacing: 0; color: #c4b5fd; border-color: rgba(196,181,253,.35); }
851
982
  .msg { word-break: break-word; color: var(--text); }
852
983
  .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
984
  .item .detail-text { margin: 8px 0 0; color: #b8c0cb; font-size: 12px; line-height: 1.55; }
@@ -1101,29 +1232,80 @@ const clipboard = new Map();
1101
1232
  let copiedKey = null;
1102
1233
 
1103
1234
  /**
1104
- * @param {{ level: string, message: string, stack?: string | null, source?: string, at: number }} item
1235
+ * @param {{
1236
+ * level: string,
1237
+ * message: string,
1238
+ * stack?: string | null,
1239
+ * source?: string,
1240
+ * page?: string | null,
1241
+ * island?: string | null,
1242
+ * url?: string | null,
1243
+ * details?: unknown,
1244
+ * at: number,
1245
+ * }} item
1105
1246
  * @returns {string}
1106
1247
  */
1107
1248
  function asText(item) {
1108
1249
  const time = new Date(item.at).toISOString();
1250
+ const details =
1251
+ item.details == null
1252
+ ? ""
1253
+ : typeof item.details === "string"
1254
+ ? item.details
1255
+ : safeJson(item.details);
1109
1256
  return [
1110
1257
  `[${item.level}] ${item.source ?? "server"} · ${time}`,
1258
+ item.page ? `page: ${item.page}` : "",
1259
+ item.island ? `island: ${item.island}` : "",
1260
+ item.url ? `url: ${item.url}` : "",
1111
1261
  item.message,
1262
+ details,
1112
1263
  item.stack ?? "",
1113
1264
  ]
1114
1265
  .filter(Boolean)
1115
1266
  .join("\n");
1116
1267
  }
1117
1268
 
1118
- /** @param {{ id?: number, level: string, message: string, stack?: string | null, source?: string, at: number }[]} list */
1269
+ /**
1270
+ * @param {unknown} value
1271
+ * @returns {string}
1272
+ */
1273
+ function formatDetails(value) {
1274
+ if (value == null) return "";
1275
+ if (typeof value === "string") return value;
1276
+ try {
1277
+ return JSON.stringify(value, null, 2);
1278
+ } catch {
1279
+ return safeJson(value);
1280
+ }
1281
+ }
1282
+
1283
+ /** @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
1284
  function errorList(list, scope) {
1120
1285
  if (!list.length) return `<div class="empty">No records.</div>`;
1121
1286
  return list
1122
1287
  .map((item) => {
1123
1288
  const key = `${scope}:${item.id ?? item.at}`;
1124
1289
  const shown = expanded.has(key);
1290
+ const detailsKey = `${key}:details`;
1291
+ const detailsShown = expanded.has(detailsKey);
1292
+ const detailsText = formatDetails(item.details);
1125
1293
  clipboard.set(key, asText(item));
1126
1294
 
1295
+ const context = [
1296
+ item.page
1297
+ ? `<span class="ctx" title="Page">${pathLink(item.page)}</span>`
1298
+ : "",
1299
+ item.island
1300
+ ? `<span class="tag island" title="Island">${escapeHtml(item.island)}</span>`
1301
+ : "",
1302
+ item.url && item.url !== item.page
1303
+ ? `<span class="ctx muted" title="Request">${escapeHtml(shortUrl(item.url))}</span>`
1304
+ : "",
1305
+ ]
1306
+ .filter(Boolean)
1307
+ .join("");
1308
+
1127
1309
  return `<div class="item ${item.level === "warn" ? "warn" : ""}">
1128
1310
  <div class="meta">
1129
1311
  <span class="tag">${escapeHtml(item.source ?? "server")}</span>
@@ -1131,7 +1313,14 @@ function errorList(list, scope) {
1131
1313
  <span class="spacer"></span>
1132
1314
  ${copyButton(key)}
1133
1315
  </div>
1316
+ ${context ? `<div class="meta ctx-row">${context}</div>` : ""}
1134
1317
  <div class="msg">${escapeHtml(item.message)}</div>
1318
+ ${
1319
+ detailsText
1320
+ ? `<button class="link" data-action="stack" data-key="${escapeHtml(detailsKey)}">${detailsShown ? "▾ hide details" : "▸ show details"}</button>
1321
+ ${detailsShown ? `<pre>${escapeHtml(detailsText)}</pre>` : ""}`
1322
+ : ""
1323
+ }
1135
1324
  ${
1136
1325
  item.stack
1137
1326
  ? `<button class="link" data-action="stack" data-key="${escapeHtml(key)}">${shown ? "▾ hide stack" : "▸ show stack"}</button>
@@ -1187,7 +1376,7 @@ function errorsTab() {
1187
1376
  ${lede(
1188
1377
  errorCount ? "bad" : total ? "mid" : "good",
1189
1378
  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.`
1379
+ ? `<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
1380
  : total
1192
1381
  ? `No errors, <strong>${total} warnings</strong>. Warnings usually come from missing data or islands that were never ported.`
1193
1382
  : "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} */
@@ -448,7 +561,7 @@ export function mountDevtools(app) {
448
561
 
449
562
  restore();
450
563
  patchConsole();
451
- trackServerFetch();
564
+ trackServerFetch({ onFailure: recordApiFailure });
452
565
  watchManifest();
453
566
  startVersionCheck();
454
567
  startHeartbeat();
@@ -18,6 +18,7 @@ import { getRedisStatus } from "../redis.js";
18
18
  import { getUpstreamLimiterStatus } from "../upstream-limiter.js";
19
19
  import { prewarmProgress } from "../prewarm.js";
20
20
  import { getConfig } from "../../config/index.js";
21
+ import { getRequestContext } from "../../http/request-context.js";
21
22
 
22
23
  /** Yollar config'ten: framework paket içine taşındığında `../..` sayan her hesap bozulur. */
23
24
  const ROOT = getConfig().root;
@@ -78,61 +79,176 @@ export function clearPageReports() {
78
79
  /* --------------------------------------------------- sunucu tarafı API çağrıları */
79
80
 
80
81
  /**
81
- * @type {{ url: string, host: string, method: string, status: number, ms: number,
82
- * bytes: number, at: number, error: string | null }[]}
82
+ * @typedef {{
83
+ * url: string,
84
+ * host: string,
85
+ * method: string,
86
+ * status: number,
87
+ * ms: number,
88
+ * bytes: number,
89
+ * at: number,
90
+ * error: string | null,
91
+ * page: string | null,
92
+ * details: unknown,
93
+ * }} ServerApiCall
83
94
  */
95
+
96
+ /** @type {ServerApiCall[]} */
84
97
  const serverApiCalls = [];
85
98
  const MAX_API_CALLS = 300;
86
99
 
100
+ /** Başarısız çağrı gövdesinin overlay'e taşınacak üst sınırı. */
101
+ const DETAILS_MAX = 4_000;
102
+
103
+ /**
104
+ * @typedef {{
105
+ * url: string,
106
+ * method: string,
107
+ * status: number,
108
+ * ms: number,
109
+ * bytes: number,
110
+ * error: string | null,
111
+ * page: string | null,
112
+ * details: unknown,
113
+ * }} ApiFailure
114
+ */
115
+
87
116
  /**
88
117
  * SSR sırasında yapılan dış çağrıları ölçer. `globalThis.fetch` sarılır;
89
118
  * yalnızca dev'de çağrıldığı için üretim yolu dokunulmaz kalır.
119
+ *
120
+ * Başarısız cevaplar (4xx/5xx ya da ağ) isteğe bağlı `onFailure` ile
121
+ * overlay hata günlüğüne de düşer — uygulama kendi logger'ıyla stderr'e
122
+ * yazsa bile panel "hangi sayfa hangi API" bilgisini görsün.
123
+ *
124
+ * @param {{ onFailure?: (call: ApiFailure) => void }} [options]
90
125
  */
91
- export function trackServerFetch() {
126
+ export function trackServerFetch(options = {}) {
92
127
  const original = globalThis.fetch;
93
- if (original.__jskeletWrapped) return;
128
+ if (/** @type {any} */ (original).__jskeletWrapped) return;
129
+
130
+ const { onFailure } = options;
94
131
 
95
132
  /** @type {typeof fetch} */
96
133
  const wrapped = async (input, init) => {
97
134
  const url = typeof input === "string" ? input : (input?.url ?? String(input));
135
+ const method = String(init?.method ?? "GET").toUpperCase();
98
136
  const started = Date.now();
137
+ const page = currentPage();
99
138
 
100
139
  // Kendi sunucumuza yapılan istekler (ısıtma, sağlık kontrolü) API sayılmaz.
101
- const isSelf = /^https?:\/\/(127\.0\.0\.1|localhost)/i.test(url);
140
+ const isSelf = /^https?:\/\/(127\.0\.0\.1|\[::1\]|localhost)(:|\/|$)/i.test(url);
102
141
 
103
142
  try {
104
143
  const response = await original(input, init);
105
144
  if (!isSelf) {
106
- push({
145
+ const details = response.ok
146
+ ? null
147
+ : await readFailureDetails(response);
148
+ const error = response.ok ? null : summarizeFailure(response.status, details);
149
+ const call = {
107
150
  url,
108
- method: init?.method ?? "GET",
151
+ method,
109
152
  status: response.status,
110
153
  ms: Date.now() - started,
111
154
  bytes: Number(response.headers.get("content-length") ?? 0),
112
- error: response.ok ? null : `HTTP ${response.status}`,
113
- });
155
+ error,
156
+ page,
157
+ details,
158
+ };
159
+ push(call);
160
+ if (error) onFailure?.(call);
114
161
  }
115
162
  return response;
116
163
  } catch (error) {
117
164
  if (!isSelf) {
118
- push({
165
+ const message = error instanceof Error ? error.message : String(error);
166
+ const call = {
119
167
  url,
120
- method: init?.method ?? "GET",
168
+ method,
121
169
  status: 0,
122
170
  ms: Date.now() - started,
123
171
  bytes: 0,
124
- error: error instanceof Error ? error.message : String(error),
125
- });
172
+ error: message,
173
+ page,
174
+ details: null,
175
+ };
176
+ push(call);
177
+ onFailure?.(call);
126
178
  }
127
179
  throw error;
128
180
  }
129
181
  };
130
182
 
131
- wrapped.__jskeletWrapped = true;
183
+ /** @type {any} */ (wrapped).__jskeletWrapped = true;
132
184
  globalThis.fetch = wrapped;
133
185
  }
134
186
 
135
- /** @param {{ url: string, method: string, status: number, ms: number, bytes: number, error: string | null }} call */
187
+ /** @returns {string | null} */
188
+ function currentPage() {
189
+ return getRequestContext()?.pathname ?? null;
190
+ }
191
+
192
+ /**
193
+ * @param {Response} response
194
+ * @returns {Promise<unknown>}
195
+ */
196
+ async function readFailureDetails(response) {
197
+ try {
198
+ const text = (await response.clone().text()).slice(0, DETAILS_MAX);
199
+ if (!text) return null;
200
+ try {
201
+ return JSON.parse(text);
202
+ } catch {
203
+ return text;
204
+ }
205
+ } catch {
206
+ return null;
207
+ }
208
+ }
209
+
210
+ /**
211
+ * Overlay başlığı: durum + varsa API'nin kendi doğrulama mesajı.
212
+ *
213
+ * @param {number} status
214
+ * @param {unknown} details
215
+ * @returns {string}
216
+ */
217
+ function summarizeFailure(status, details) {
218
+ const label = `HTTP ${status}`;
219
+ const detail = failureMessage(details);
220
+ return detail ? `${label}: ${detail}` : label;
221
+ }
222
+
223
+ /**
224
+ * Upstream gövdesinden kısa, okunabilir bir cümle çıkarır. `[object Object]`
225
+ * basmamak için nesneleri bilinçli dolaşır.
226
+ *
227
+ * @param {unknown} details
228
+ * @returns {string | null}
229
+ */
230
+ function failureMessage(details) {
231
+ if (details == null) return null;
232
+ if (typeof details === "string") return details.slice(0, 280);
233
+ if (typeof details !== "object") return String(details);
234
+
235
+ const record = /** @type {Record<string, unknown>} */ (details);
236
+ for (const key of ["details", "detail", "message", "error", "title"]) {
237
+ const value = record[key];
238
+ if (typeof value === "string" && value.trim()) return value.slice(0, 280);
239
+ if (value && typeof value === "object") {
240
+ const nested = failureMessage(value);
241
+ if (nested) return nested;
242
+ }
243
+ }
244
+ try {
245
+ return JSON.stringify(details).slice(0, 280);
246
+ } catch {
247
+ return null;
248
+ }
249
+ }
250
+
251
+ /** @param {Omit<ServerApiCall, "host" | "at">} call */
136
252
  function push(call) {
137
253
  let host = "—";
138
254
  try {
@@ -304,7 +304,11 @@ export function route(controller, options = {}) {
304
304
  const isPrivate = options.private === true;
305
305
 
306
306
  return async (req, res, next) => {
307
- const context = createRequestContext({ private: isPrivate, res });
307
+ const context = createRequestContext({
308
+ private: isPrivate,
309
+ res,
310
+ pathname: req.path,
311
+ });
308
312
  const ctx = {
309
313
  params: req.params ?? {},
310
314
  query: req.query ?? {},
@@ -442,7 +446,11 @@ export function route(controller, options = {}) {
442
446
  */
443
447
  export function fragment(controller) {
444
448
  return async (req, res, next) => {
445
- const context = createRequestContext({ private: true, res });
449
+ const context = createRequestContext({
450
+ private: true,
451
+ res,
452
+ pathname: req.path,
453
+ });
446
454
  const ctx = {
447
455
  params: req.params ?? {},
448
456
  query: req.query ?? {},