jskelet 0.6.12 → 0.6.14

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
@@ -8,8 +8,23 @@ one is listed under a **Breaking** heading.
8
8
 
9
9
  ## [Unreleased]
10
10
 
11
+ ### Fixed
12
+
13
+ - S3 PutObject uses path-style URLs for bucket names that contain dots
14
+ (e.g. `static.investvio.dev`). Virtual-hosted
15
+ `bucket.s3.region.amazonaws.com` fails TLS because the cert only covers
16
+ `*.s3.region.amazonaws.com` (one label). Fetch errors are also shortened.
17
+
11
18
  ### Added
12
19
 
20
+ - Dev overlay **API** tab (between Performance and Server): outbound
21
+ `fetch` calls for the current page — browser and SSR — with host
22
+ rollup, duration, status, and a detail pane on click (component,
23
+ URL, timing, size, response body when captured). Browser calls are
24
+ attributed to the island/module (mount, stack, or last interaction);
25
+ SSR calls to the first `lib/` / `routes/` / `controllers/` / `views/`
26
+ frame. The live stats channel includes recent server API calls so the
27
+ tab updates without a refresh.
13
28
  - Optional CDN asset publishing via `build.assets`: `prefix` rewrites
14
29
  `asset()` / optimised image URLs (manifest stays relative on disk);
15
30
  `upload.bucket` / `keyPrefix` / `region` / `endpoint` come from config only.
@@ -631,7 +631,7 @@ değişmez** — asset'ler origin'den servis edilir.
631
631
  | --- | --- | --- | --- |
632
632
  | `assets.prefix` | `string` | `""` | CDN origin; `asset()` URL öneki (sonunda `/` yok). Yalnızca config. Manifest diskte göreli kalır. |
633
633
  | `assets.upload.enabled` | `boolean` | `false` | `true` veya config'te `bucket` doluysa açılır; açık `false` ezer. |
634
- | `assets.upload.bucket` | `string \| null` | `null` | **S3 bucket adı** (`static.ornek.com` gibi noktalı adlar geçerli) veya `bucket/key/prefix`. `https://…` şeması kabul edilmez — o `prefix`. |
634
+ | `assets.upload.bucket` | `string \| null` | `null` | **S3 bucket adı** (`static.ornek.com` gibi noktalı adlar geçerli; PutObject path-style kullanır) veya `bucket/key/prefix`. `https://…` şeması kabul edilmez — o `prefix`. |
635
635
  | `assets.upload.keyPrefix` | `string` | `""` | Nesne anahtarı öneki; bucket yolunda verilmişse o kazanır (`prefix` takma adı da kabul). |
636
636
  | `assets.upload.region` | `string` | `us-east-1` | Config; custom `endpoint` varken varsayılan `auto`. |
637
637
  | `assets.upload.endpoint` | `string \| null` | `null` | S3-uyumlu API (R2 / MinIO); config. |
@@ -196,6 +196,12 @@ Gösterdikleri:
196
196
  dikdörtgenler çizilir, kenarda kısa başlık durur. Etikete (ya da panel
197
197
  satırına) tıklanınca ayrıntılı açıklama açılır. Tarama overlay'in yanındaki
198
198
  `/seo.js` dosyasındadır; production paketine girmez.
199
+ - **API:** açık sayfanın dışarı giden `fetch` çağrıları — tarayıcıdaki
200
+ island/veri istekleri ve SSR sırasında yapılan upstream çağrıları.
201
+ Host kırılımı, toplam/ortalama süre, durum kodu; satıra tıklanınca
202
+ **component** (island adı veya `lib/` / `routes/` dosyası), yöntem,
203
+ tam URL, boyut ve (başarısızsa) yanıt gövdesi. Canlı kanal `serverApi`
204
+ listesini de taşır, böylece SSR çağrıları yenileme olmadan görünür.
199
205
  - **İstekler:** her HTML isteğinin metodu, yolu, durumu, süresi ve
200
206
  `X-JSkelet-Cache` değeri. Aynı satırlar terminale de basılır.
201
207
  - **Web Vitals:** TTFB, FCP, LCP, CLS, INP, DCL, load, uzun task sayısı ve
@@ -644,7 +644,7 @@ changes** — assets stay on the origin.
644
644
  | --- | --- | --- | --- |
645
645
  | `assets.prefix` | `string` | `""` | CDN origin; prefix for `asset()` URLs (no trailing `/`). Config only. The on-disk manifest stays relative. |
646
646
  | `assets.upload.enabled` | `boolean` | `false` | Turns on when `true` or when `bucket` is set in config; explicit `false` wins. |
647
- | `assets.upload.bucket` | `string \| null` | `null` | **S3 bucket name** (dotted names like `static.example.com` are fine) or `bucket/key/prefix`. A `https://…` scheme is rejected — that belongs in `prefix`. |
647
+ | `assets.upload.bucket` | `string \| null` | `null` | **S3 bucket name** (dotted names like `static.example.com` are fine; PutObject uses path-style) or `bucket/key/prefix`. A `https://…` scheme is rejected — that belongs in `prefix`. |
648
648
  | `assets.upload.keyPrefix` | `string` | `""` | Object key prefix; a path on the bucket wins (`prefix` is accepted as an alias). |
649
649
  | `assets.upload.region` | `string` | `us-east-1` | Config; defaults to `auto` when `endpoint` is set. |
650
650
  | `assets.upload.endpoint` | `string \| null` | `null` | S3-compatible API (R2 / MinIO); config. |
@@ -201,6 +201,12 @@ What it shows:
201
201
  title on the border. Clicking the label (or a row in the panel) opens the
202
202
  full explanation. The scan lives in `/seo.js` next to the overlay and is not
203
203
  part of the production bundle.
204
+ - **API:** outbound `fetch` calls for the open page — browser island/data
205
+ requests and upstream calls made during SSR. Host rollup, total/average
206
+ duration, status codes; click a row for the **component** (island name
207
+ or `lib/` / `routes/` file), method, full URL, size and (on failure)
208
+ the response body. The live channel also carries the `serverApi` list
209
+ so SSR calls appear without a refresh.
204
210
  - **Requests:** the method, path, status, duration and `X-JSkelet-Cache` value
205
211
  of every HTML request. The same lines are printed to the terminal too.
206
212
  - **Web Vitals:** TTFB, FCP, LCP, CLS, INP, DCL, load, long task count and
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jskelet",
3
- "version": "0.6.12",
3
+ "version": "0.6.14",
4
4
  "description": "A framework that feels like no framework: Express 5 + build-time .jsk SSR (optional EJS peer), vanilla JS islands, Tailwind v4 and an in-process HTML TTL cache.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * Dev overlay: sağ altta yüzen baloncuk; hataları, performans
3
- * istatistiklerini ve SEO taramasını gösterir.
3
+ * istatistiklerini, sayfa API çağrılarını ve SEO taramasını gösterir.
4
4
  *
5
5
  * Bu dosya **build'e dahil değildir**. `npm run dev` sırasında sunucu onu
6
6
  * `/__jskelet/dev/overlay.js` altından ham olarak servis eder, layout da
@@ -46,6 +46,9 @@ let expanded = new Set();
46
46
  let seoHighlight = false;
47
47
  /** Panel listesinde açılan sorun açıklaması. */
48
48
  let seoExpandedId = null;
49
+
50
+ /** API sekmesinde açılan çağrı; `side:id` biçiminde. */
51
+ let selectedApiKey = null;
49
52
  /** @type {import('./seo.js').SeoIssue[]} */
50
53
  let seoIssues = [];
51
54
  const seoLayer = new SeoHighlighter();
@@ -323,14 +326,54 @@ const islandSummary = cached(() => ({
323
326
 
324
327
  /* --------------------------------------------------------- API çağrıları */
325
328
 
326
- /** @type {{ url: string, ms: number, status: number, bytes: number, initiator: string }[]} */
329
+ /**
330
+ * @typedef {{
331
+ * id: number,
332
+ * url: string,
333
+ * method: string,
334
+ * ms: number,
335
+ * status: number,
336
+ * bytes: number,
337
+ * contentType: string | null,
338
+ * initiator: string,
339
+ * island: string | null,
340
+ * component: string | null,
341
+ * page: string,
342
+ * at: number,
343
+ * error: string | null,
344
+ * details: unknown,
345
+ * }} ClientApiCall
346
+ */
347
+
348
+ /** @type {ClientApiCall[]} */
327
349
  const clientApi = [];
350
+ let nextApiId = 1;
351
+
352
+ /**
353
+ * Son etkileşilen island. Mount bittikten sonra event handler içindeki
354
+ * `fetch` yığında dosya adı her zaman görünmeyebiliyor; tıklanan island
355
+ * köke bakmak o boşluğu doldurur.
356
+ */
357
+ function trackIslandInteraction() {
358
+ /** @param {Event} event */
359
+ const mark = (event) => {
360
+ const target = /** @type {Element | null} */ (event.target);
361
+ const host = target?.closest?.("[data-island]");
362
+ const name = host?.getAttribute?.("data-island");
363
+ if (name) globalThis.__JSKELET_LAST_ISLAND = name;
364
+ };
365
+
366
+ for (const type of ["pointerdown", "click", "keydown", "submit", "change"]) {
367
+ document.addEventListener(type, mark, true);
368
+ }
369
+ }
328
370
 
329
371
  /**
330
372
  * Tarayıcıdaki veri çağrıları. Panelin kendi uçları ve statik varlıklar
331
373
  * sayılmaz; amaç sayfanın hangi API'lere kaç ms harcadığını görmek.
332
374
  * 4xx/5xx ve ağ hataları Errors sekmesine de düşer — hangi sayfa / island
333
- * bağlamı mümkün olduğunca doldurulur.
375
+ * bağlamı mümkün olduğunca doldurulur. Aynı kayıtlar API sekmesinde de
376
+ * listelenir.
334
377
  */
335
378
  function captureFetch() {
336
379
  const original = window.fetch.bind(window);
@@ -343,9 +386,10 @@ function captureFetch() {
343
386
  const started = performance.now();
344
387
  try {
345
388
  const response = await original(input, init);
346
- record(url, performance.now() - started, response.status, response);
389
+ const entry = record(url, method, performance.now() - started, response.status, response);
347
390
  if (!response.ok) {
348
391
  void reportFailedFetch({
392
+ id: entry.id,
349
393
  url,
350
394
  method,
351
395
  status: response.status,
@@ -354,7 +398,9 @@ function captureFetch() {
354
398
  }
355
399
  return response;
356
400
  } catch (error) {
357
- record(url, performance.now() - started, 0, null);
401
+ const entry = record(url, method, performance.now() - started, 0, null);
402
+ entry.error = "network error";
403
+ entry.details = error instanceof Error ? error.message : String(error);
358
404
  pushError(
359
405
  "error",
360
406
  `${method} ${shortUrl(url)} → network error`,
@@ -362,9 +408,9 @@ function captureFetch() {
362
408
  source: "fetch",
363
409
  url,
364
410
  page: location.pathname + location.search,
365
- island: inferIsland(),
411
+ island: entry.island,
366
412
  stack: error instanceof Error ? error.stack : null,
367
- details: error instanceof Error ? error.message : String(error),
413
+ details: entry.details,
368
414
  },
369
415
  );
370
416
  throw error;
@@ -373,31 +419,58 @@ function captureFetch() {
373
419
 
374
420
  /**
375
421
  * @param {string} url
422
+ * @param {string} method
376
423
  * @param {number} ms
377
424
  * @param {number} status
378
425
  * @param {Response | null} response
426
+ * @returns {ClientApiCall}
379
427
  */
380
- function record(url, ms, status, response) {
381
- clientApi.push({
428
+ function record(url, method, ms, status, response) {
429
+ const component = inferComponent();
430
+ /** @type {ClientApiCall} */
431
+ const entry = {
432
+ id: nextApiId++,
382
433
  url,
434
+ method,
383
435
  ms: Math.round(ms),
384
436
  status,
385
437
  bytes: Number(response?.headers.get("content-length") ?? 0),
386
- initiator: "fetch",
387
- });
438
+ contentType: response?.headers.get("content-type") ?? null,
439
+ initiator: "client",
440
+ island: component,
441
+ component,
442
+ page: location.pathname + location.search,
443
+ at: Date.now(),
444
+ error: status === 0 || (status >= 400) ? `HTTP ${status || "network"}` : null,
445
+ details: null,
446
+ };
447
+ clientApi.push(entry);
388
448
  if (clientApi.length > 100) clientApi.shift();
449
+ // API sekmesi açıkken yeni çağrı hemen görünsün.
450
+ lastBody = "";
451
+ render();
452
+ return entry;
389
453
  }
390
454
  }
391
455
 
392
456
  /**
393
457
  * Başarısız bir tarayıcı fetch'ini Errors listesine yazar. Gövdeyi klonlayıp
394
- * okur; asıl Response tüketilmez.
458
+ * okur; asıl Response tüketilmez. Aynı gövde API satırının detayına da yazılır.
395
459
  *
396
- * @param {{ url: string, method: string, status: number, response: Response }} call
460
+ * @param {{ id: number, url: string, method: string, status: number, response: Response }} call
397
461
  */
398
462
  async function reportFailedFetch(call) {
399
463
  const details = await readResponseDetails(call.response);
400
464
  const summary = detailSummary(details);
465
+ const entry = clientApi.find((item) => item.id === call.id);
466
+ if (entry) {
467
+ entry.details = details;
468
+ entry.error = summary
469
+ ? `HTTP ${call.status}: ${summary}`
470
+ : `HTTP ${call.status}`;
471
+ lastBody = "";
472
+ render();
473
+ }
401
474
  pushError(
402
475
  "error",
403
476
  `${call.method} ${shortUrl(call.url)} → ${call.status}${
@@ -407,7 +480,7 @@ async function reportFailedFetch(call) {
407
480
  source: "fetch",
408
481
  url: call.url,
409
482
  page: location.pathname + location.search,
410
- island: inferIsland(),
483
+ island: entry?.island ?? inferIsland(),
411
484
  details,
412
485
  },
413
486
  );
@@ -468,18 +541,59 @@ function shortUrl(url) {
468
541
  }
469
542
 
470
543
  /**
471
- * Çağrı yığınında `islands/ad` izi varsa ya da bir island mount edilirken
472
- * yakalanmışsa ada bağlar.
544
+ * Çağrıyı hangi island / client modülünden geldiğine bağlar.
545
+ * Sıra: mount işareti → yığın izi → son etkileşilen island.
473
546
  *
474
547
  * @returns {string | null}
475
548
  */
476
- function inferIsland() {
549
+ function inferComponent() {
477
550
  const mounting = globalThis.__JSKELET_MOUNTING_ISLAND;
478
551
  if (typeof mounting === "string" && mounting) return mounting;
479
552
 
480
- const stack = new Error().stack ?? "";
481
- const match = stack.match(/islands[/\\]([A-Za-z0-9_-]+)/);
482
- return match?.[1] ?? null;
553
+ const fromStack = componentFromClientStack(new Error().stack ?? "");
554
+ if (fromStack) return fromStack;
555
+
556
+ const last = globalThis.__JSKELET_LAST_ISLAND;
557
+ if (typeof last === "string" && last) return last;
558
+
559
+ return null;
560
+ }
561
+
562
+ /** @deprecated {@link inferComponent} */
563
+ function inferIsland() {
564
+ return inferComponent();
565
+ }
566
+
567
+ /**
568
+ * @param {string} stack
569
+ * @returns {string | null}
570
+ */
571
+ function componentFromClientStack(stack) {
572
+ const islandPath = stack.match(/islands[/\\]([A-Za-z0-9_-]+)/);
573
+ if (islandPath) return islandPath[1];
574
+
575
+ // Build çıktısı: /assets/islands/counter-a1b2c3.js
576
+ const assetIsland = stack.match(/[/\\]islands[/\\]([A-Za-z0-9_-]+?)(?:-[a-z0-9]+)?\.(?:js|mjs|ts|tsx)/i);
577
+ if (assetIsland) return assetIsland[1];
578
+
579
+ const clientPath = stack.match(/client[/\\]([A-Za-z0-9_./\\-]+\.[A-Za-z0-9]+)/);
580
+ if (clientPath) return `client/${clientPath[1].replace(/\\/g, "/")}`;
581
+
582
+ // Kayıtlı island adları yığında (hash'li chunk URL'sinde) geçiyorsa.
583
+ for (const node of document.querySelectorAll("[data-island]")) {
584
+ const name = node.getAttribute("data-island");
585
+ if (!name || name.length < 2) continue;
586
+ if (
587
+ stack.includes(`/${name}.`) ||
588
+ stack.includes(`/${name}-`) ||
589
+ stack.includes(`islands/${name}`) ||
590
+ stack.includes(`islands%2F${name}`)
591
+ ) {
592
+ return name;
593
+ }
594
+ }
595
+
596
+ return null;
483
597
  }
484
598
 
485
599
  /**
@@ -734,6 +848,8 @@ async function startPrewarm(paths) {
734
848
 
735
849
  async function clearAll() {
736
850
  clientErrors.length = 0;
851
+ clientApi.length = 0;
852
+ selectedApiKey = null;
737
853
  try {
738
854
  await fetch(`${BASE}/clear`, { method: "POST" });
739
855
  } catch {
@@ -1047,6 +1163,27 @@ h4 .head-action { float: inline-end; text-transform: none; letter-spacing: 0; }
1047
1163
  .status.bad { background: rgba(251,113,133,.14); color: var(--bad); }
1048
1164
  .hint { color: var(--muted); font-size: 10.5px; }
1049
1165
  .row .good { color: var(--good); } .row .mid { color: var(--mid); } .row .bad { color: var(--bad); }
1166
+ .row.clickable { cursor: pointer; grid-template-columns: auto auto 1fr auto auto; }
1167
+ .row.clickable.selected { border-color: rgba(255,255,255,.22); background: var(--surface-hover); }
1168
+ .row .method {
1169
+ font-size: 10px; font-weight: 700; letter-spacing: .04em; color: #c4b5fd;
1170
+ min-width: 2.6em; text-align: center;
1171
+ }
1172
+ .api-detail {
1173
+ margin: 0 0 8px; padding: 12px 14px; border-radius: 12px;
1174
+ background: rgba(0,0,0,.35); border: 1px solid var(--line);
1175
+ font-size: 12px; line-height: 1.55;
1176
+ }
1177
+ .api-detail .kv { display: grid; grid-template-columns: 88px 1fr; gap: 6px 12px; margin: 0 0 10px; }
1178
+ .api-detail .kv dt { color: var(--muted); font-size: 10.5px; text-transform: uppercase; letter-spacing: .05em; font-weight: 600; }
1179
+ .api-detail .kv dd { margin: 0; color: #dbe2ea; word-break: break-all; font-family: ui-monospace, SFMono-Regular, monospace; font-size: 11.5px; }
1180
+ .api-detail .good { color: var(--good); } .api-detail .mid { color: var(--mid); } .api-detail .bad { color: var(--bad); }
1181
+ .api-detail pre {
1182
+ margin: 0; padding: 8px 10px; background: rgba(0,0,0,.4); border-radius: 8px;
1183
+ white-space: pre-wrap; word-break: break-word; color: #b8c0cb;
1184
+ font-size: 11px; font-family: ui-monospace, SFMono-Regular, monospace;
1185
+ max-height: 220px; overflow: auto;
1186
+ }
1050
1187
 
1051
1188
  .btn {
1052
1189
  background: var(--surface); border: 1px solid var(--line); color: var(--text);
@@ -1079,6 +1216,7 @@ const TAB_ICONS = {
1079
1216
  errors: `<svg viewBox="0 0 16 16"><path d="M8 5.5v4M8 11.5h.01M8 1.8 1.8 13.2h12.4L8 1.8Z"/></svg>`,
1080
1217
  seo: `<svg viewBox="0 0 16 16"><circle cx="7.2" cy="7.2" r="4.4"/><path d="M10.4 10.4 13.6 13.6M5.2 7.2h4M7.2 5.2v4"/></svg>`,
1081
1218
  perf: `<svg viewBox="0 0 16 16"><path d="M2 13V8m4 5V3m4 10V6m4 7v-3"/></svg>`,
1219
+ api: `<svg viewBox="0 0 16 16"><path d="M2.5 8h3l1.5-3.5L9.5 12 11 8h2.5"/><circle cx="3.2" cy="8" r=".7"/><circle cx="12.8" cy="8" r=".7"/></svg>`,
1082
1220
  server: `<svg viewBox="0 0 16 16"><path d="M2.5 3.5h11v3h-11zM2.5 9.5h11v3h-11zM5 5h.01M5 11h.01"/></svg>`,
1083
1221
  warm: `<svg viewBox="0 0 16 16"><path d="M8 14c2.5 0 4.2-1.7 4.2-3.9C12.2 6.5 8 2 8 2S3.8 6.5 3.8 10.1C3.8 12.3 5.5 14 8 14Z"/></svg>`,
1084
1222
  about: `<svg viewBox="0 0 16 16"><circle cx="8" cy="8" r="6.2"/><path d="M8 7.2v4M8 4.9h.01"/></svg>`,
@@ -1105,6 +1243,7 @@ const SKELETON = `
1105
1243
  <button class="tab" data-tab="errors">${TAB_ICONS.errors} Errors<span class="chip" data-part="chip" hidden></span></button>
1106
1244
  <button class="tab" data-tab="seo">${TAB_ICONS.seo} SEO<span class="chip" data-part="seo-chip" hidden></span></button>
1107
1245
  <button class="tab" data-tab="perf">${TAB_ICONS.perf} Performance</button>
1246
+ <button class="tab" data-tab="api">${TAB_ICONS.api} API<span class="chip neutral" data-part="api-chip" hidden></span></button>
1108
1247
  <button class="tab" data-tab="server">${TAB_ICONS.server} Server<span class="chip neutral" data-part="version-chip" hidden>update</span></button>
1109
1248
  <button class="tab" data-tab="warm">${TAB_ICONS.warm} Prewarming<span class="chip neutral" data-part="warm-chip" hidden></span></button>
1110
1249
  <div class="rail-foot">
@@ -1572,6 +1711,266 @@ function versionNotice() {
1572
1711
  );
1573
1712
  }
1574
1713
 
1714
+ /**
1715
+ * Bu sayfanın tarayıcı + SSR API çağrılarını birleştirir.
1716
+ *
1717
+ * @returns {{
1718
+ * key: string,
1719
+ * side: "client" | "server",
1720
+ * method: string,
1721
+ * url: string,
1722
+ * host: string,
1723
+ * status: number,
1724
+ * ms: number,
1725
+ * bytes: number,
1726
+ * contentType: string | null,
1727
+ * component: string | null,
1728
+ * page: string | null,
1729
+ * at: number,
1730
+ * error: string | null,
1731
+ * details: unknown,
1732
+ * }[]}
1733
+ */
1734
+ function pageApiCalls() {
1735
+ const pagePath = location.pathname;
1736
+
1737
+ const client = clientApi.map((call) => {
1738
+ let host = location.host;
1739
+ try {
1740
+ host = new URL(call.url, location.origin).host;
1741
+ } catch {
1742
+ // göreli / bozuk URL
1743
+ }
1744
+ return {
1745
+ key: `client:${call.id}`,
1746
+ side: /** @type {const} */ ("client"),
1747
+ method: call.method,
1748
+ url: call.url,
1749
+ host,
1750
+ status: call.status,
1751
+ ms: call.ms,
1752
+ bytes: call.bytes,
1753
+ contentType: call.contentType,
1754
+ component: call.component ?? call.island,
1755
+ page: call.page,
1756
+ at: call.at,
1757
+ error: call.error,
1758
+ details: call.details,
1759
+ };
1760
+ });
1761
+
1762
+ const server = (serverStats?.serverApi ?? [])
1763
+ .filter((call) => {
1764
+ if (!call.page) return false;
1765
+ return call.page.split("?")[0] === pagePath;
1766
+ })
1767
+ .map((call, index) => ({
1768
+ key: `server:${call.at}:${index}`,
1769
+ side: /** @type {const} */ ("server"),
1770
+ method: call.method,
1771
+ url: call.url,
1772
+ host: call.host ?? "—",
1773
+ status: call.status,
1774
+ ms: Math.round(call.ms),
1775
+ bytes: call.bytes ?? 0,
1776
+ contentType: null,
1777
+ component: call.component ?? null,
1778
+ page: call.page,
1779
+ at: call.at,
1780
+ error: call.error,
1781
+ details: call.details,
1782
+ }));
1783
+
1784
+ return [...client, ...server].sort((a, b) => b.at - a.at);
1785
+ }
1786
+
1787
+ function apiTab() {
1788
+ const calls = pageApiCalls();
1789
+ const failed = calls.filter((call) => call.error || call.status >= 400 || call.status === 0);
1790
+ const totalMs = calls.reduce((sum, call) => sum + call.ms, 0);
1791
+ const byHost = new Map();
1792
+
1793
+ for (const call of calls) {
1794
+ const bucket = byHost.get(call.host) ?? { count: 0, ms: 0 };
1795
+ bucket.count += 1;
1796
+ bucket.ms += call.ms;
1797
+ byHost.set(call.host, bucket);
1798
+ }
1799
+
1800
+ const hosts = [...byHost.entries()]
1801
+ .sort((a, b) => b[1].ms - a[1].ms)
1802
+ .slice(0, 8);
1803
+
1804
+ clipboard.set(
1805
+ "api",
1806
+ calls
1807
+ .map(
1808
+ (call) =>
1809
+ `${new Date(call.at).toISOString()} [${call.side}] ${call.component ?? "—"} ${call.method} ${call.url} ${call.status} ${call.ms}ms${
1810
+ call.error ? ` ${call.error}` : ""
1811
+ }`,
1812
+ )
1813
+ .join("\n"),
1814
+ );
1815
+
1816
+ const selected = selectedApiKey
1817
+ ? calls.find((call) => call.key === selectedApiKey) ?? null
1818
+ : null;
1819
+
1820
+ const tone = failed.length
1821
+ ? "bad"
1822
+ : calls.some((call) => call.ms > 500)
1823
+ ? "mid"
1824
+ : calls.length
1825
+ ? "good"
1826
+ : "mid";
1827
+
1828
+ return `
1829
+ ${lede(
1830
+ tone,
1831
+ calls.length
1832
+ ? `This page made <strong>${calls.length} API call${calls.length === 1 ? "" : "s"}</strong> ` +
1833
+ `(${clientApi.length} in the browser, ${calls.length - clientApi.length} during SSR), ` +
1834
+ `taking <strong>${ms(totalMs)}</strong> in total` +
1835
+ `${failed.length ? ` · <strong>${failed.length} failed</strong>` : ""}. ` +
1836
+ `Click a row for the component, host, timing and response details.`
1837
+ : "No outbound API calls recorded for this page yet. Browser <code>fetch</code> and SSR upstream calls show up here as they happen.",
1838
+ )}
1839
+ ${
1840
+ calls.length
1841
+ ? `${metricGrid([
1842
+ { label: "Calls", value: String(calls.length) },
1843
+ { label: "Total time", value: ms(totalMs), tone: grade(totalMs, [300, 1200]), ratio: ratio(totalMs, 1200) },
1844
+ {
1845
+ label: "Avg",
1846
+ value: ms(totalMs / calls.length),
1847
+ tone: grade(totalMs / calls.length, [150, 500]),
1848
+ },
1849
+ {
1850
+ label: "Failed",
1851
+ value: String(failed.length),
1852
+ tone: failed.length ? "bad" : "good",
1853
+ },
1854
+ { label: "Hosts", value: String(byHost.size) },
1855
+ { label: "Browser", value: String(clientApi.length) },
1856
+ { label: "SSR", value: String(calls.length - clientApi.length) },
1857
+ ])}
1858
+ ${
1859
+ hosts.length
1860
+ ? `<h4>By host</h4>
1861
+ <div class="rows">${hosts
1862
+ .map(
1863
+ ([host, info]) => `<div class="row">
1864
+ <span class="tag">${info.count}×</span>
1865
+ <span class="path">${escapeHtml(host)}</span>
1866
+ <span class="hint">${Math.round((info.ms / Math.max(totalMs, 1)) * 100)}%</span>
1867
+ <span class="${grade(info.ms / info.count, [150, 500])}">${ms(info.ms)}</span>
1868
+ </div>`,
1869
+ )
1870
+ .join("")}</div>`
1871
+ : ""
1872
+ }
1873
+ <h4>Calls<span class="head-action">${copyButton("api", "copy all")}</span></h4>
1874
+ ${
1875
+ selected
1876
+ ? apiDetailPanel(selected)
1877
+ : `<div class="empty" style="margin-bottom:8px">Select a call to inspect headers, timing and the response body when available.</div>`
1878
+ }
1879
+ <div class="rows">${calls
1880
+ .map((call) => {
1881
+ const bad = call.error || call.status >= 400 || call.status === 0;
1882
+ const warn = !bad && call.status >= 300;
1883
+ return `<div class="row clickable ${selectedApiKey === call.key ? "selected" : ""}" data-action="api-select" data-key="${escapeHtml(call.key)}">
1884
+ <span class="status ${bad ? "bad" : warn ? "warn" : ""}">${call.status || "—"}</span>
1885
+ <span class="method">${escapeHtml(call.method)}</span>
1886
+ <span class="path" title="${escapeHtml(call.url)}">${escapeHtml(shortUrl(call.url))}</span>
1887
+ <span class="hint" title="${escapeHtml(call.component ?? call.side)}">${
1888
+ call.component
1889
+ ? escapeHtml(shortComponent(call.component))
1890
+ : escapeHtml(call.side)
1891
+ }</span>
1892
+ <span class="${grade(call.ms, [150, 500])}">${call.ms} ms</span>
1893
+ </div>`;
1894
+ })
1895
+ .join("")}</div>`
1896
+ : ""
1897
+ }
1898
+ `;
1899
+ }
1900
+
1901
+ /** @param {string} value */
1902
+ function shortComponent(value) {
1903
+ const parts = value.replace(/\\/g, "/").split("/");
1904
+ return parts.length > 2 ? parts.slice(-2).join("/") : value;
1905
+ }
1906
+
1907
+ /**
1908
+ * @param {ReturnType<typeof pageApiCalls>[number]} call
1909
+ */
1910
+ function apiDetailPanel(call) {
1911
+ const detailKey = `api-detail:${call.key}`;
1912
+ clipboard.set(
1913
+ detailKey,
1914
+ [
1915
+ `${call.method} ${call.url}`,
1916
+ `status: ${call.status}`,
1917
+ `ms: ${call.ms}`,
1918
+ `bytes: ${call.bytes}`,
1919
+ `side: ${call.side}`,
1920
+ call.component ? `component: ${call.component}` : "",
1921
+ call.host ? `host: ${call.host}` : "",
1922
+ call.page ? `page: ${call.page}` : "",
1923
+ call.contentType ? `content-type: ${call.contentType}` : "",
1924
+ call.error ? `error: ${call.error}` : "",
1925
+ call.details == null
1926
+ ? ""
1927
+ : typeof call.details === "string"
1928
+ ? call.details
1929
+ : safeJson(call.details),
1930
+ ]
1931
+ .filter(Boolean)
1932
+ .join("\n"),
1933
+ );
1934
+
1935
+ const detailsText = formatDetails(call.details);
1936
+
1937
+ return `<div class="api-detail">
1938
+ <div class="meta" style="display:flex;align-items:center;gap:8px;margin-bottom:10px;color:var(--muted);font-size:10.5px">
1939
+ <span class="tag">${escapeHtml(call.side)}</span>
1940
+ ${
1941
+ call.component
1942
+ ? `<span class="tag island" title="Component">${escapeHtml(call.component)}</span>`
1943
+ : ""
1944
+ }
1945
+ <span>${new Date(call.at).toLocaleTimeString()}</span>
1946
+ <span class="spacer"></span>
1947
+ ${copyButton(detailKey, "copy")}
1948
+ <button class="link" data-action="api-select" data-key="${escapeHtml(call.key)}" style="margin:0">close</button>
1949
+ </div>
1950
+ <dl class="kv">
1951
+ <dt>Component</dt><dd>${
1952
+ call.component
1953
+ ? escapeHtml(call.component)
1954
+ : `<span class="hint">unknown</span>`
1955
+ }</dd>
1956
+ <dt>Method</dt><dd>${escapeHtml(call.method)}</dd>
1957
+ <dt>URL</dt><dd>${escapeHtml(call.url)}</dd>
1958
+ <dt>Host</dt><dd>${escapeHtml(call.host)}</dd>
1959
+ <dt>Status</dt><dd class="${call.error || call.status >= 400 || call.status === 0 ? "bad" : call.status >= 300 ? "mid" : "good"}">${call.status || "—"}</dd>
1960
+ <dt>Duration</dt><dd class="${grade(call.ms, [150, 500])}">${call.ms} ms</dd>
1961
+ <dt>Size</dt><dd>${call.bytes ? kb(call.bytes) : "—"}</dd>
1962
+ ${call.contentType ? `<dt>Type</dt><dd>${escapeHtml(call.contentType)}</dd>` : ""}
1963
+ ${call.page ? `<dt>Page</dt><dd>${pathLink(call.page)}</dd>` : ""}
1964
+ ${call.error ? `<dt>Error</dt><dd class="bad">${escapeHtml(call.error)}</dd>` : ""}
1965
+ </dl>
1966
+ ${
1967
+ detailsText
1968
+ ? `<h4 style="margin:0 0 8px">Response body</h4><pre>${escapeHtml(detailsText)}</pre>`
1969
+ : `<div class="hint">No response body captured${call.side === "client" && !call.error ? " (successful browser calls keep the body for the page)" : ""}.</div>`
1970
+ }
1971
+ </div>`;
1972
+ }
1973
+
1575
1974
  function serverTab() {
1576
1975
  if (!serverStats) return `<div class="empty">Waiting for server stats…</div>`;
1577
1976
 
@@ -1807,7 +2206,7 @@ const ABOUT = {
1807
2206
  <div class="step"><span class="n">2</span><span><b>Islands</b><span>Every interactive piece is marked with <code>data-island</code> and its module loads once it enters the viewport. The whole page is never hydrated.</span></span></div>
1808
2207
  <div class="step"><span class="n">3</span><span><b>Build pipeline</b><span>esbuild compiles JS, Tailwind + LightningCSS the styles, sharp the images, Phosphor the icons; the output is hashed and written to <code>manifest.json</code>.</span></span></div>
1809
2208
  <div class="step"><span class="n">4</span><span><b>Dev loop</b><span>Sources are watched: on a CSS change the stylesheet is swapped, on a JS or server change a single full reload happens.</span></span></div>
1810
- <div class="step"><span class="n">5</span><span><b>Observability</b><span>This panel collects errors, Web Vitals measurements and server requests; records survive a server restart.</span></span></div>
2209
+ <div class="step"><span class="n">5</span><span><b>Observability</b><span>This panel collects errors, Web Vitals, outbound API calls and server requests; records survive a server restart.</span></span></div>
1811
2210
  </div>
1812
2211
  <div class="cards">
1813
2212
  <div class="card"><b>Runtime</b><span>Node + Express 5, ESM. Page templates are EJS, the data layer is the services under <code>lib/</code>.</span></div>
@@ -2126,6 +2525,16 @@ function paint() {
2126
2525
  versionChip.hidden = !version?.outdated;
2127
2526
  if (version?.outdated) versionChip.title = `JSkelet ${version.latest} is available`;
2128
2527
 
2528
+ const apiCalls = pageApiCalls();
2529
+ const apiChip = root.querySelector("[data-part='api-chip']");
2530
+ apiChip.hidden = !apiCalls.length;
2531
+ apiChip.className = `chip ${
2532
+ apiCalls.some((call) => call.error || call.status >= 400 || call.status === 0)
2533
+ ? ""
2534
+ : "neutral"
2535
+ }`;
2536
+ apiChip.textContent = String(apiCalls.length);
2537
+
2129
2538
  const warm = serverStats?.prewarm;
2130
2539
  const warmChip = root.querySelector("[data-part='warm-chip']");
2131
2540
  warmChip.hidden = !warm?.total;
@@ -2149,11 +2558,13 @@ function paint() {
2149
2558
  ? seoTab()
2150
2559
  : activeTab === "perf"
2151
2560
  ? perfTab()
2152
- : activeTab === "about"
2153
- ? aboutTab()
2154
- : activeTab === "warm"
2155
- ? warmTab()
2156
- : serverTab();
2561
+ : activeTab === "api"
2562
+ ? apiTab()
2563
+ : activeTab === "about"
2564
+ ? aboutTab()
2565
+ : activeTab === "warm"
2566
+ ? warmTab()
2567
+ : serverTab();
2157
2568
 
2158
2569
  if (html !== lastBody) {
2159
2570
  const body = root.querySelector(".body");
@@ -2238,6 +2649,14 @@ function bind(root) {
2238
2649
  return;
2239
2650
  }
2240
2651
 
2652
+ if (target.dataset.action === "api-select") {
2653
+ const key = target.dataset.key ?? null;
2654
+ selectedApiKey = selectedApiKey === key ? null : key;
2655
+ lastBody = "";
2656
+ render();
2657
+ return;
2658
+ }
2659
+
2241
2660
  if (target.dataset.action === "toggle") {
2242
2661
  setOpen(!open);
2243
2662
  return;
@@ -2323,6 +2742,7 @@ function start() {
2323
2742
  captureErrors();
2324
2743
  captureMetrics();
2325
2744
  captureFetch();
2745
+ trackIslandInteraction();
2326
2746
  bind(ensureRoot());
2327
2747
  refreshSeo();
2328
2748
  render();
@@ -19,6 +19,7 @@ import { clearHtmlCache } from "../html-cache.js";
19
19
  import {
20
20
  buildReport,
21
21
  clearPageReports,
22
+ getServerApiCalls,
22
23
  recordPageReport,
23
24
  trackServerFetch,
24
25
  } from "./report.js";
@@ -326,6 +327,9 @@ function statsPayload() {
326
327
  prewarm: { ...prewarmProgress },
327
328
  requests: requests.slice(-25).reverse(),
328
329
  errors: errors.slice(-25).reverse(),
330
+ // SSR dış çağrıları: overlay API sekmesi hangi sayfanın hangi
331
+ // upstream'e kaç ms harcadığını buradan okur.
332
+ serverApi: getServerApiCalls(50),
329
333
  };
330
334
  }
331
335
 
@@ -565,7 +569,11 @@ export function mountDevtools(app) {
565
569
 
566
570
  restore();
567
571
  patchConsole();
568
- trackServerFetch({ onFailure: recordApiFailure });
572
+ trackServerFetch({
573
+ onFailure: recordApiFailure,
574
+ // Başarılı çağrılar Errors'a düşmez ama API sekmesi onları da görmeli.
575
+ onCall: () => pushStats(),
576
+ });
569
577
  watchManifest();
570
578
  startVersionCheck();
571
579
  startHeartbeat();
@@ -89,6 +89,7 @@ export function clearPageReports() {
89
89
  * at: number,
90
90
  * error: string | null,
91
91
  * page: string | null,
92
+ * component: string | null,
92
93
  * details: unknown,
93
94
  * }} ServerApiCall
94
95
  */
@@ -109,6 +110,7 @@ const DETAILS_MAX = 4_000;
109
110
  * bytes: number,
110
111
  * error: string | null,
111
112
  * page: string | null,
113
+ * component: string | null,
112
114
  * details: unknown,
113
115
  * }} ApiFailure
114
116
  */
@@ -121,13 +123,19 @@ const DETAILS_MAX = 4_000;
121
123
  * overlay hata günlüğüne de düşer — uygulama kendi logger'ıyla stderr'e
122
124
  * yazsa bile panel "hangi sayfa hangi API" bilgisini görsün.
123
125
  *
124
- * @param {{ onFailure?: (call: ApiFailure) => void }} [options]
126
+ * Her dış çağrı (başarılı ya da değil) `onCall` ile de bildirilir; overlay
127
+ * API sekmesi canlı kanal üzerinden güncellensin diye.
128
+ *
129
+ * @param {{
130
+ * onFailure?: (call: ApiFailure) => void,
131
+ * onCall?: (call: ApiFailure) => void,
132
+ * }} [options]
125
133
  */
126
134
  export function trackServerFetch(options = {}) {
127
135
  const original = globalThis.fetch;
128
136
  if (/** @type {any} */ (original).__jskeletWrapped) return;
129
137
 
130
- const { onFailure } = options;
138
+ const { onFailure, onCall } = options;
131
139
 
132
140
  /** @type {typeof fetch} */
133
141
  const wrapped = async (input, init) => {
@@ -135,6 +143,8 @@ export function trackServerFetch(options = {}) {
135
143
  const method = String(init?.method ?? "GET").toUpperCase();
136
144
  const started = Date.now();
137
145
  const page = currentPage();
146
+ // Yığını çağrı anında al: await sonrası stack sarmalayıcıda kalır.
147
+ const component = inferServerComponent();
138
148
 
139
149
  // Kendi sunucumuza yapılan istekler (ısıtma, sağlık kontrolü) API sayılmaz.
140
150
  const isSelf = /^https?:\/\/(127\.0\.0\.1|\[::1\]|localhost)(:|\/|$)/i.test(url);
@@ -154,9 +164,11 @@ export function trackServerFetch(options = {}) {
154
164
  bytes: Number(response.headers.get("content-length") ?? 0),
155
165
  error,
156
166
  page,
167
+ component,
157
168
  details,
158
169
  };
159
170
  push(call);
171
+ onCall?.(call);
160
172
  if (error) onFailure?.(call);
161
173
  }
162
174
  return response;
@@ -171,9 +183,11 @@ export function trackServerFetch(options = {}) {
171
183
  bytes: 0,
172
184
  error: message,
173
185
  page,
186
+ component,
174
187
  details: null,
175
188
  };
176
189
  push(call);
190
+ onCall?.(call);
177
191
  onFailure?.(call);
178
192
  }
179
193
  throw error;
@@ -184,11 +198,43 @@ export function trackServerFetch(options = {}) {
184
198
  globalThis.fetch = wrapped;
185
199
  }
186
200
 
201
+ /**
202
+ * Overlay ve istatistik uçları için son dış çağrıları döner (yeniden eskiye).
203
+ *
204
+ * @param {number} [limit]
205
+ * @returns {ServerApiCall[]}
206
+ */
207
+ export function getServerApiCalls(limit = 50) {
208
+ return serverApiCalls.slice(-limit).reverse();
209
+ }
210
+
187
211
  /** @returns {string | null} */
188
212
  function currentPage() {
189
213
  return getRequestContext()?.pathname ?? null;
190
214
  }
191
215
 
216
+ /**
217
+ * SSR `fetch` çağrısını uygulamanın hangi dosyasından geldiğine bağlar.
218
+ * Framework ve `node_modules` kareleri atlanır; `lib/`, `routes/`,
219
+ * `controllers/` veya `views/` altındaki ilk kare alınır.
220
+ *
221
+ * @returns {string | null}
222
+ */
223
+ function inferServerComponent() {
224
+ const stack = new Error().stack ?? "";
225
+ for (const line of stack.split("\n")) {
226
+ if (/node_modules|server[/\\]dev[/\\]|node:internal/i.test(line)) continue;
227
+
228
+ const match = line.match(
229
+ /((?:lib|routes|controllers|views)[/\\][A-Za-z0-9_./\\-]+\.[A-Za-z0-9]+)/i,
230
+ );
231
+ if (!match) continue;
232
+
233
+ return match[1].replace(/\\/g, "/");
234
+ }
235
+ return null;
236
+ }
237
+
192
238
  /**
193
239
  * @param {Response} response
194
240
  * @returns {Promise<unknown>}
@@ -116,6 +116,21 @@ export function encodeS3Path(value) {
116
116
  .join("/");
117
117
  }
118
118
 
119
+ /**
120
+ * Virtual-hosted (`bucket.s3.region…`) TLS sertifikası `*.s3.region…` —
121
+ * yalnızca **tek** etiket. `static.investvio.dev` gibi noktalı bucket'lar
122
+ * `Host: static.investvio.dev.s3.…` üretir ve `ERR_TLS_CERT_ALTNAME_INVALID`
123
+ * verir. Path-style (`s3.region…/bucket/key`) bunu çözer.
124
+ *
125
+ * @param {string} bucket
126
+ * @param {string | null | undefined} endpoint
127
+ * @returns {boolean}
128
+ */
129
+ export function useS3PathStyle(bucket, endpoint) {
130
+ if (endpoint) return true;
131
+ return bucket.includes(".");
132
+ }
133
+
119
134
  /**
120
135
  * PutObject isteğini imzalar; ağ çağrısı yapmaz — test edilebilir olsun diye.
121
136
  *
@@ -130,13 +145,24 @@ export function buildSignedPutObject(input) {
130
145
  const payloadHash = sha256Hex(body);
131
146
 
132
147
  const keyPath = encodeS3Path(input.key.replace(/^\/+/, ""));
133
- const usePathStyle = Boolean(input.endpoint);
134
- const endpointHost = input.endpoint
135
- ? new URL(input.endpoint).host
136
- : `${input.bucket}.s3.${input.region}.amazonaws.com`;
137
- const host = usePathStyle ? new URL(input.endpoint ?? "").host : endpointHost;
148
+ const pathStyle = useS3PathStyle(input.bucket, input.endpoint);
149
+
150
+ /** @type {string} */
151
+ let host;
152
+ /** @type {string} */
153
+ let base;
154
+ if (input.endpoint) {
155
+ base = String(input.endpoint).replace(/\/+$/, "");
156
+ host = new URL(input.endpoint).host;
157
+ } else if (pathStyle) {
158
+ host = `s3.${input.region}.amazonaws.com`;
159
+ base = `https://${host}`;
160
+ } else {
161
+ host = `${input.bucket}.s3.${input.region}.amazonaws.com`;
162
+ base = `https://${host}`;
163
+ }
138
164
 
139
- const canonicalUri = usePathStyle
165
+ const canonicalUri = pathStyle
140
166
  ? `/${encodeS3Path(input.bucket)}/${keyPath}`
141
167
  : `/${keyPath}`;
142
168
 
@@ -186,10 +212,7 @@ export function buildSignedPutObject(input) {
186
212
  `AWS4-HMAC-SHA256 Credential=${input.credentials.accessKeyId}/${credentialScope}, ` +
187
213
  `SignedHeaders=${signedHeaders}, Signature=${signature}`;
188
214
 
189
- const base = usePathStyle
190
- ? String(input.endpoint).replace(/\/+$/, "")
191
- : `https://${host}`;
192
- const url = usePathStyle
215
+ const url = pathStyle
193
216
  ? `${base}/${encodeS3Path(input.bucket)}/${keyPath}`
194
217
  : `${base}/${keyPath}`;
195
218
 
@@ -202,11 +225,25 @@ export function buildSignedPutObject(input) {
202
225
  */
203
226
  export async function putObject(input) {
204
227
  const signed = buildSignedPutObject(input);
205
- const response = await fetch(signed.url, {
206
- method: "PUT",
207
- headers: signed.headers,
208
- body: signed.body,
209
- });
228
+ /** @type {Response} */
229
+ let response;
230
+ try {
231
+ response = await fetch(signed.url, {
232
+ method: "PUT",
233
+ headers: signed.headers,
234
+ body: signed.body,
235
+ });
236
+ } catch (error) {
237
+ const cause =
238
+ error && typeof error === "object" && "cause" in error && error.cause
239
+ ? /** @type {{ message?: string, code?: string }} */ (error.cause)
240
+ : null;
241
+ const detail =
242
+ cause?.code ||
243
+ cause?.message ||
244
+ (error instanceof Error ? error.message : String(error));
245
+ throw new Error(`S3 PutObject fetch failed (${signed.url}): ${detail}`);
246
+ }
210
247
 
211
248
  if (!response.ok) {
212
249
  const text = await response.text().catch(() => "");
@@ -44,6 +44,7 @@ export type ServerApiCall = {
44
44
  at: number;
45
45
  error: string | null;
46
46
  page: string | null;
47
+ component: string | null;
47
48
  details: unknown;
48
49
  };
49
50
  export type ApiFailure = {
@@ -54,6 +55,7 @@ export type ApiFailure = {
54
55
  bytes: number;
55
56
  error: string | null;
56
57
  page: string | null;
58
+ component: string | null;
57
59
  details: unknown;
58
60
  };
59
61
  /**
@@ -65,6 +67,7 @@ export type ApiFailure = {
65
67
  * bytes: number,
66
68
  * error: string | null,
67
69
  * page: string | null,
70
+ * component: string | null,
68
71
  * details: unknown,
69
72
  * }} ApiFailure
70
73
  */
@@ -76,11 +79,25 @@ export type ApiFailure = {
76
79
  * overlay hata günlüğüne de düşer — uygulama kendi logger'ıyla stderr'e
77
80
  * yazsa bile panel "hangi sayfa hangi API" bilgisini görsün.
78
81
  *
79
- * @param {{ onFailure?: (call: ApiFailure) => void }} [options]
82
+ * Her dış çağrı (başarılı ya da değil) `onCall` ile de bildirilir; overlay
83
+ * API sekmesi canlı kanal üzerinden güncellensin diye.
84
+ *
85
+ * @param {{
86
+ * onFailure?: (call: ApiFailure) => void,
87
+ * onCall?: (call: ApiFailure) => void,
88
+ * }} [options]
80
89
  */
81
90
  export declare function trackServerFetch(options?: {
82
91
  onFailure?: (call: ApiFailure) => void;
92
+ onCall?: (call: ApiFailure) => void;
83
93
  }): void;
94
+ /**
95
+ * Overlay ve istatistik uçları için son dış çağrıları döner (yeniden eskiye).
96
+ *
97
+ * @param {number} [limit]
98
+ * @returns {ServerApiCall[]}
99
+ */
100
+ export declare function getServerApiCalls(limit?: number): ServerApiCall[];
84
101
  /**
85
102
  * Rapor sayfasının tek veri kaynağı.
86
103
  * @param {{ requests: object[], errors: object[] }} devtools
@@ -69,6 +69,17 @@ export declare function buildStringToSign(parts: {
69
69
  * @returns {string}
70
70
  */
71
71
  export declare function encodeS3Path(value: string): string;
72
+ /**
73
+ * Virtual-hosted (`bucket.s3.region…`) TLS sertifikası `*.s3.region…` —
74
+ * yalnızca **tek** etiket. `static.investvio.dev` gibi noktalı bucket'lar
75
+ * `Host: static.investvio.dev.s3.…` üretir ve `ERR_TLS_CERT_ALTNAME_INVALID`
76
+ * verir. Path-style (`s3.region…/bucket/key`) bunu çözer.
77
+ *
78
+ * @param {string} bucket
79
+ * @param {string | null | undefined} endpoint
80
+ * @returns {boolean}
81
+ */
82
+ export declare function useS3PathStyle(bucket: string, endpoint: string | null | undefined): boolean;
72
83
  /**
73
84
  * PutObject isteğini imzalar; ağ çağrısı yapmaz — test edilebilir olsun diye.
74
85
  *