jskelet 0.6.11 → 0.6.13

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,21 @@ 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 (URL, timing,
23
+ size, island, response body when captured). The live stats channel
24
+ now includes recent server API calls so the tab updates without a
25
+ refresh.
13
26
  - Optional CDN asset publishing via `build.assets`: `prefix` rewrites
14
27
  `asset()` / optimised image URLs (manifest stays relative on disk);
15
28
  `upload.bucket` / `keyPrefix` / `region` / `endpoint` come from config only.
@@ -19,9 +32,11 @@ one is listed under a **Breaking** heading.
19
32
  `JSKELET_S3_*`, then ECS task-role / EC2 instance-profile metadata). The
20
33
  Assets upload step always appears on production builds; when off it logs a
21
34
  one-line reason (`skipped (no build.assets.upload.bucket)`,
22
- `skipped (credentials missing)`, …). A URL in `upload.bucket` is rejected
23
- with a warning (CDN origin belongs in `prefix`). Watch mode never uploads.
24
- `.br` / `.gz` siblings are skipped.
35
+ `skipped (credentials missing (no env keys, no ECS task role …))`, …).
36
+ Docker/`RUN jskelet build` has no task role — upload needs CI role or a
37
+ runtime upload. A URL in `upload.bucket` is rejected with a warning (CDN
38
+ origin belongs in `prefix`). Watch mode never uploads. `.br` / `.gz`
39
+ siblings are skipped.
25
40
 
26
41
  ### Changed
27
42
 
@@ -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
+ yöntem, tam URL, boyut, island ve (başarısızsa) yanıt gövdesi. Canlı
203
+ kanal `serverApi` listesini de taşır, böylece SSR çağrıları yenileme
204
+ 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
@@ -55,34 +55,41 @@ ayarlamayı düşünmeniz gerekenler:
55
55
 
56
56
  ### Asset'leri S3 / CloudFront'a basmak
57
57
 
58
- Varsayılan kapalıdır. Config'te prefix + bucket; CI'da yalnızca credential:
58
+ Varsayılan kapalıdır. Config'te prefix + bucket; credential env key **veya**
59
+ IAM role:
59
60
 
60
61
  ```js
61
- // jskelet.config.mjs
62
+ // jskelet.config.mjs — uygulama env'lerini buraya bağla
62
63
  build: {
63
64
  assets: {
64
- prefix: "https://static.ornek.com/js",
65
+ prefix: process.env.NEXT_PUBLIC_STATIC_ASSETS_BASE_URL, // https://static.…
65
66
  upload: {
66
- bucket: "static.ornek.com", // veya "static.ornek.com/js"
67
+ bucket: process.env.S3_BUCKET_NAME, // static.investvio.com
67
68
  keyPrefix: "js",
68
- region: "eu-central-1",
69
+ region: process.env.AWS_REGION || "eu-central-1",
70
+ endpoint: process.env.AWS_S3_ENDPOINT || undefined,
69
71
  },
70
72
  },
71
73
  },
72
74
  ```
73
75
 
74
- ```bash
75
- # CI / local: explicit keys. ECS/EC2'de task/instance role yeter — key şart değil.
76
- AWS_ACCESS_KEY_ID=…
77
- AWS_SECRET_ACCESS_KEY=…
78
- # AWS_SESSION_TOKEN=…
79
- ```
76
+ **Task role ne zaman çalışır?** ECS task role /
77
+ `AWS_CONTAINER_CREDENTIALS_RELATIVE_URI` yalnızca **görev çalışırken** vardır.
78
+ Dockerfile içindeki `RUN jskelet build` veya CI build agent'ında bu URI
79
+ yoktur → `skipped (credentials missing (no env keys, no ECS task role …))`.
80
+ AWS SDK'nın runtime'da rolü görmesi, build adımının rolü görmesi demek değildir.
81
+
82
+ Pratik seçenekler:
83
+
84
+ 1. **CI'da rol ile build** (CodeBuild / GitHub OIDC) — static key yok, upload
85
+ build job'unda biter.
86
+ 2. **Image'da yalnızca derle**, upload'u ECS start'ta ayrı koş (task role o an
87
+ vardır).
88
+ 3. CI'da geçici `AWS_*` key (tercih edilmez).
80
89
 
81
- `jskelet build` bitince `public/assets/` (ve config'te `include` edilen
82
- dizinler) S3'e kopyalanır; origin'deki dosyalar da durur. Bucket veya
83
- credential yoksa adım `skipped (…)` ile sebep basar, build devam eder. Watch /
84
- `jskelet dev` upload yapmaz. CSP kullanıyorsanız CDN origin'ini `script-src` /
85
- `style-src` / `img-src` ve `preconnect` listesine ekleyin.
90
+ `jskelet build` bitince `public/assets/` S3'e kopyalanır; origin kopyası durur.
91
+ Bucket/credential yoksa adım `skipped (…)` basar. Watch / `jskelet dev` upload
92
+ yapmaz. CSP'ye CDN origin ekleyin.
86
93
 
87
94
  Production'da dosya veya S3 sink açıldığında HTTP access log middleware
88
95
  otomatik mount edilir (`logs.kinds` içinde `http` varsa). Admin paneli ring'i
@@ -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,11 @@ 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 method, full URL, size, island
207
+ and (on failure) the response body. The live channel also carries the
208
+ `serverApi` list so SSR calls appear without a refresh.
204
209
  - **Requests:** the method, path, status, duration and `X-JSkelet-Cache` value
205
210
  of every HTML request. The same lines are printed to the terminal too.
206
211
  - **Web Vitals:** TTFB, FCP, LCP, CLS, INP, DCL, load, long task count and
@@ -56,34 +56,40 @@ considering in production:
56
56
 
57
57
  ### Publishing assets to S3 / CloudFront
58
58
 
59
- Off by default. Prefix + bucket in config; credentials only in CI:
59
+ Off by default. Prefix + bucket in config; credentials from env keys **or** an
60
+ IAM role:
60
61
 
61
62
  ```js
62
- // jskelet.config.mjs
63
+ // jskelet.config.mjs — wire your app env vars here
63
64
  build: {
64
65
  assets: {
65
- prefix: "https://static.example.com/js",
66
+ prefix: process.env.NEXT_PUBLIC_STATIC_ASSETS_BASE_URL, // https://static.…
66
67
  upload: {
67
- bucket: "static.example.com", // or "static.example.com/js"
68
+ bucket: process.env.S3_BUCKET_NAME, // static.example.com
68
69
  keyPrefix: "js",
69
- region: "eu-central-1",
70
+ region: process.env.AWS_REGION || "eu-central-1",
71
+ endpoint: process.env.AWS_S3_ENDPOINT || undefined,
70
72
  },
71
73
  },
72
74
  },
73
75
  ```
74
76
 
75
- ```bash
76
- # CI / local: explicit keys. On ECS/EC2 a task/instance role is enough — no keys required.
77
- AWS_ACCESS_KEY_ID=…
78
- AWS_SECRET_ACCESS_KEY=…
79
- # AWS_SESSION_TOKEN=…
80
- ```
77
+ **When does the task role work?** The ECS task role /
78
+ `AWS_CONTAINER_CREDENTIALS_RELATIVE_URI` exist only while the **task is
79
+ running**. A `RUN jskelet build` inside Docker or a CI build agent has no such
80
+ URI → `skipped (credentials missing (no env keys, no ECS task role …))`. The AWS
81
+ SDK seeing the role at runtime does not mean the build step sees it.
82
+
83
+ Practical options:
84
+
85
+ 1. **Build with a CI role** (CodeBuild / GitHub OIDC) — no static keys; upload
86
+ finishes in the job.
87
+ 2. **Compile in the image**, run upload at ECS start (task role is present then).
88
+ 3. Temporary `AWS_*` keys in CI (not preferred).
81
89
 
82
- When `jskelet build` finishes, files under `public/assets/` (and any other
83
- `include` directories) are copied to S3; origin copies remain. If the bucket or
84
- credentials are missing, the step logs `skipped (…)` with the reason and the
85
- build continues. Watch / `jskelet dev` never upload. If you use CSP, add the CDN
86
- origin to `script-src` / `style-src` / `img-src` and to `preconnect`.
90
+ When `jskelet build` finishes, `public/assets/` is copied to S3; origin copies
91
+ remain. Missing bucket/credentials log `skipped (…)`. Watch / `jskelet dev`
92
+ never upload. Add the CDN origin to CSP.
87
93
 
88
94
  When a file or S3 sink is enabled in production, the HTTP access log middleware
89
95
  mounts automatically (if `http` is in `logs.kinds`). The admin panel ring is
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jskelet",
3
- "version": "0.6.11",
3
+ "version": "0.6.13",
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",
@@ -12,7 +12,10 @@
12
12
  */
13
13
  import fs from "node:fs";
14
14
  import path from "node:path";
15
- import { resolveS3Credentials } from "../../server/logs/s3-credentials.js";
15
+ import {
16
+ resolveS3Credentials,
17
+ s3CredentialsMissingReason,
18
+ } from "../../server/logs/s3-credentials.js";
16
19
  import { putObject } from "../../server/logs/s3-put.js";
17
20
  import * as log from "../../log.mjs";
18
21
  import { paths } from "../paths.mjs";
@@ -127,7 +130,7 @@ export async function uploadAssets(config, options = {}) {
127
130
  const resolve = options.resolveCredentials ?? resolveS3Credentials;
128
131
  const credentials = await resolve();
129
132
  if (!upload.bucket || !credentials) {
130
- log.detail("skipped (credentials missing)");
133
+ log.detail(`skipped (${s3CredentialsMissingReason()})`);
131
134
  return;
132
135
  }
133
136
 
@@ -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,34 @@ 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
+ * page: string,
341
+ * at: number,
342
+ * error: string | null,
343
+ * details: unknown,
344
+ * }} ClientApiCall
345
+ */
346
+
347
+ /** @type {ClientApiCall[]} */
327
348
  const clientApi = [];
349
+ let nextApiId = 1;
328
350
 
329
351
  /**
330
352
  * Tarayıcıdaki veri çağrıları. Panelin kendi uçları ve statik varlıklar
331
353
  * sayılmaz; amaç sayfanın hangi API'lere kaç ms harcadığını görmek.
332
354
  * 4xx/5xx ve ağ hataları Errors sekmesine de düşer — hangi sayfa / island
333
- * bağlamı mümkün olduğunca doldurulur.
355
+ * bağlamı mümkün olduğunca doldurulur. Aynı kayıtlar API sekmesinde de
356
+ * listelenir.
334
357
  */
335
358
  function captureFetch() {
336
359
  const original = window.fetch.bind(window);
@@ -343,9 +366,10 @@ function captureFetch() {
343
366
  const started = performance.now();
344
367
  try {
345
368
  const response = await original(input, init);
346
- record(url, performance.now() - started, response.status, response);
369
+ const entry = record(url, method, performance.now() - started, response.status, response);
347
370
  if (!response.ok) {
348
371
  void reportFailedFetch({
372
+ id: entry.id,
349
373
  url,
350
374
  method,
351
375
  status: response.status,
@@ -354,7 +378,9 @@ function captureFetch() {
354
378
  }
355
379
  return response;
356
380
  } catch (error) {
357
- record(url, performance.now() - started, 0, null);
381
+ const entry = record(url, method, performance.now() - started, 0, null);
382
+ entry.error = "network error";
383
+ entry.details = error instanceof Error ? error.message : String(error);
358
384
  pushError(
359
385
  "error",
360
386
  `${method} ${shortUrl(url)} → network error`,
@@ -362,9 +388,9 @@ function captureFetch() {
362
388
  source: "fetch",
363
389
  url,
364
390
  page: location.pathname + location.search,
365
- island: inferIsland(),
391
+ island: entry.island,
366
392
  stack: error instanceof Error ? error.stack : null,
367
- details: error instanceof Error ? error.message : String(error),
393
+ details: entry.details,
368
394
  },
369
395
  );
370
396
  throw error;
@@ -373,31 +399,56 @@ function captureFetch() {
373
399
 
374
400
  /**
375
401
  * @param {string} url
402
+ * @param {string} method
376
403
  * @param {number} ms
377
404
  * @param {number} status
378
405
  * @param {Response | null} response
406
+ * @returns {ClientApiCall}
379
407
  */
380
- function record(url, ms, status, response) {
381
- clientApi.push({
408
+ function record(url, method, ms, status, response) {
409
+ /** @type {ClientApiCall} */
410
+ const entry = {
411
+ id: nextApiId++,
382
412
  url,
413
+ method,
383
414
  ms: Math.round(ms),
384
415
  status,
385
416
  bytes: Number(response?.headers.get("content-length") ?? 0),
386
- initiator: "fetch",
387
- });
417
+ contentType: response?.headers.get("content-type") ?? null,
418
+ initiator: "client",
419
+ island: inferIsland(),
420
+ page: location.pathname + location.search,
421
+ at: Date.now(),
422
+ error: status === 0 || (status >= 400) ? `HTTP ${status || "network"}` : null,
423
+ details: null,
424
+ };
425
+ clientApi.push(entry);
388
426
  if (clientApi.length > 100) clientApi.shift();
427
+ // API sekmesi açıkken yeni çağrı hemen görünsün.
428
+ lastBody = "";
429
+ render();
430
+ return entry;
389
431
  }
390
432
  }
391
433
 
392
434
  /**
393
435
  * Başarısız bir tarayıcı fetch'ini Errors listesine yazar. Gövdeyi klonlayıp
394
- * okur; asıl Response tüketilmez.
436
+ * okur; asıl Response tüketilmez. Aynı gövde API satırının detayına da yazılır.
395
437
  *
396
- * @param {{ url: string, method: string, status: number, response: Response }} call
438
+ * @param {{ id: number, url: string, method: string, status: number, response: Response }} call
397
439
  */
398
440
  async function reportFailedFetch(call) {
399
441
  const details = await readResponseDetails(call.response);
400
442
  const summary = detailSummary(details);
443
+ const entry = clientApi.find((item) => item.id === call.id);
444
+ if (entry) {
445
+ entry.details = details;
446
+ entry.error = summary
447
+ ? `HTTP ${call.status}: ${summary}`
448
+ : `HTTP ${call.status}`;
449
+ lastBody = "";
450
+ render();
451
+ }
401
452
  pushError(
402
453
  "error",
403
454
  `${call.method} ${shortUrl(call.url)} → ${call.status}${
@@ -407,7 +458,7 @@ async function reportFailedFetch(call) {
407
458
  source: "fetch",
408
459
  url: call.url,
409
460
  page: location.pathname + location.search,
410
- island: inferIsland(),
461
+ island: entry?.island ?? inferIsland(),
411
462
  details,
412
463
  },
413
464
  );
@@ -734,6 +785,8 @@ async function startPrewarm(paths) {
734
785
 
735
786
  async function clearAll() {
736
787
  clientErrors.length = 0;
788
+ clientApi.length = 0;
789
+ selectedApiKey = null;
737
790
  try {
738
791
  await fetch(`${BASE}/clear`, { method: "POST" });
739
792
  } catch {
@@ -1047,6 +1100,27 @@ h4 .head-action { float: inline-end; text-transform: none; letter-spacing: 0; }
1047
1100
  .status.bad { background: rgba(251,113,133,.14); color: var(--bad); }
1048
1101
  .hint { color: var(--muted); font-size: 10.5px; }
1049
1102
  .row .good { color: var(--good); } .row .mid { color: var(--mid); } .row .bad { color: var(--bad); }
1103
+ .row.clickable { cursor: pointer; grid-template-columns: auto auto 1fr auto auto; }
1104
+ .row.clickable.selected { border-color: rgba(255,255,255,.22); background: var(--surface-hover); }
1105
+ .row .method {
1106
+ font-size: 10px; font-weight: 700; letter-spacing: .04em; color: #c4b5fd;
1107
+ min-width: 2.6em; text-align: center;
1108
+ }
1109
+ .api-detail {
1110
+ margin: 0 0 8px; padding: 12px 14px; border-radius: 12px;
1111
+ background: rgba(0,0,0,.35); border: 1px solid var(--line);
1112
+ font-size: 12px; line-height: 1.55;
1113
+ }
1114
+ .api-detail .kv { display: grid; grid-template-columns: 88px 1fr; gap: 6px 12px; margin: 0 0 10px; }
1115
+ .api-detail .kv dt { color: var(--muted); font-size: 10.5px; text-transform: uppercase; letter-spacing: .05em; font-weight: 600; }
1116
+ .api-detail .kv dd { margin: 0; color: #dbe2ea; word-break: break-all; font-family: ui-monospace, SFMono-Regular, monospace; font-size: 11.5px; }
1117
+ .api-detail .good { color: var(--good); } .api-detail .mid { color: var(--mid); } .api-detail .bad { color: var(--bad); }
1118
+ .api-detail pre {
1119
+ margin: 0; padding: 8px 10px; background: rgba(0,0,0,.4); border-radius: 8px;
1120
+ white-space: pre-wrap; word-break: break-word; color: #b8c0cb;
1121
+ font-size: 11px; font-family: ui-monospace, SFMono-Regular, monospace;
1122
+ max-height: 220px; overflow: auto;
1123
+ }
1050
1124
 
1051
1125
  .btn {
1052
1126
  background: var(--surface); border: 1px solid var(--line); color: var(--text);
@@ -1079,6 +1153,7 @@ const TAB_ICONS = {
1079
1153
  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
1154
  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
1155
  perf: `<svg viewBox="0 0 16 16"><path d="M2 13V8m4 5V3m4 10V6m4 7v-3"/></svg>`,
1156
+ 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
1157
  server: `<svg viewBox="0 0 16 16"><path d="M2.5 3.5h11v3h-11zM2.5 9.5h11v3h-11zM5 5h.01M5 11h.01"/></svg>`,
1083
1158
  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
1159
  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 +1180,7 @@ const SKELETON = `
1105
1180
  <button class="tab" data-tab="errors">${TAB_ICONS.errors} Errors<span class="chip" data-part="chip" hidden></span></button>
1106
1181
  <button class="tab" data-tab="seo">${TAB_ICONS.seo} SEO<span class="chip" data-part="seo-chip" hidden></span></button>
1107
1182
  <button class="tab" data-tab="perf">${TAB_ICONS.perf} Performance</button>
1183
+ <button class="tab" data-tab="api">${TAB_ICONS.api} API<span class="chip neutral" data-part="api-chip" hidden></span></button>
1108
1184
  <button class="tab" data-tab="server">${TAB_ICONS.server} Server<span class="chip neutral" data-part="version-chip" hidden>update</span></button>
1109
1185
  <button class="tab" data-tab="warm">${TAB_ICONS.warm} Prewarming<span class="chip neutral" data-part="warm-chip" hidden></span></button>
1110
1186
  <div class="rail-foot">
@@ -1572,6 +1648,247 @@ function versionNotice() {
1572
1648
  );
1573
1649
  }
1574
1650
 
1651
+ /**
1652
+ * Bu sayfanın tarayıcı + SSR API çağrılarını birleştirir.
1653
+ *
1654
+ * @returns {{
1655
+ * key: string,
1656
+ * side: "client" | "server",
1657
+ * method: string,
1658
+ * url: string,
1659
+ * host: string,
1660
+ * status: number,
1661
+ * ms: number,
1662
+ * bytes: number,
1663
+ * contentType: string | null,
1664
+ * island: string | null,
1665
+ * page: string | null,
1666
+ * at: number,
1667
+ * error: string | null,
1668
+ * details: unknown,
1669
+ * }[]}
1670
+ */
1671
+ function pageApiCalls() {
1672
+ const pagePath = location.pathname;
1673
+
1674
+ const client = clientApi.map((call) => {
1675
+ let host = location.host;
1676
+ try {
1677
+ host = new URL(call.url, location.origin).host;
1678
+ } catch {
1679
+ // göreli / bozuk URL
1680
+ }
1681
+ return {
1682
+ key: `client:${call.id}`,
1683
+ side: /** @type {const} */ ("client"),
1684
+ method: call.method,
1685
+ url: call.url,
1686
+ host,
1687
+ status: call.status,
1688
+ ms: call.ms,
1689
+ bytes: call.bytes,
1690
+ contentType: call.contentType,
1691
+ island: call.island,
1692
+ page: call.page,
1693
+ at: call.at,
1694
+ error: call.error,
1695
+ details: call.details,
1696
+ };
1697
+ });
1698
+
1699
+ const server = (serverStats?.serverApi ?? [])
1700
+ .filter((call) => {
1701
+ if (!call.page) return false;
1702
+ return call.page.split("?")[0] === pagePath;
1703
+ })
1704
+ .map((call, index) => ({
1705
+ key: `server:${call.at}:${index}`,
1706
+ side: /** @type {const} */ ("server"),
1707
+ method: call.method,
1708
+ url: call.url,
1709
+ host: call.host ?? "—",
1710
+ status: call.status,
1711
+ ms: Math.round(call.ms),
1712
+ bytes: call.bytes ?? 0,
1713
+ contentType: null,
1714
+ island: null,
1715
+ page: call.page,
1716
+ at: call.at,
1717
+ error: call.error,
1718
+ details: call.details,
1719
+ }));
1720
+
1721
+ return [...client, ...server].sort((a, b) => b.at - a.at);
1722
+ }
1723
+
1724
+ function apiTab() {
1725
+ const calls = pageApiCalls();
1726
+ const failed = calls.filter((call) => call.error || call.status >= 400 || call.status === 0);
1727
+ const totalMs = calls.reduce((sum, call) => sum + call.ms, 0);
1728
+ const byHost = new Map();
1729
+
1730
+ for (const call of calls) {
1731
+ const bucket = byHost.get(call.host) ?? { count: 0, ms: 0 };
1732
+ bucket.count += 1;
1733
+ bucket.ms += call.ms;
1734
+ byHost.set(call.host, bucket);
1735
+ }
1736
+
1737
+ const hosts = [...byHost.entries()]
1738
+ .sort((a, b) => b[1].ms - a[1].ms)
1739
+ .slice(0, 8);
1740
+
1741
+ clipboard.set(
1742
+ "api",
1743
+ calls
1744
+ .map(
1745
+ (call) =>
1746
+ `${new Date(call.at).toISOString()} [${call.side}] ${call.method} ${call.url} ${call.status} ${call.ms}ms${
1747
+ call.error ? ` ${call.error}` : ""
1748
+ }`,
1749
+ )
1750
+ .join("\n"),
1751
+ );
1752
+
1753
+ const selected = selectedApiKey
1754
+ ? calls.find((call) => call.key === selectedApiKey) ?? null
1755
+ : null;
1756
+
1757
+ const tone = failed.length
1758
+ ? "bad"
1759
+ : calls.some((call) => call.ms > 500)
1760
+ ? "mid"
1761
+ : calls.length
1762
+ ? "good"
1763
+ : "mid";
1764
+
1765
+ return `
1766
+ ${lede(
1767
+ tone,
1768
+ calls.length
1769
+ ? `This page made <strong>${calls.length} API call${calls.length === 1 ? "" : "s"}</strong> ` +
1770
+ `(${clientApi.length} in the browser, ${calls.length - clientApi.length} during SSR), ` +
1771
+ `taking <strong>${ms(totalMs)}</strong> in total` +
1772
+ `${failed.length ? ` · <strong>${failed.length} failed</strong>` : ""}. ` +
1773
+ `Click a row for method, host, timing and response details.`
1774
+ : "No outbound API calls recorded for this page yet. Browser <code>fetch</code> and SSR upstream calls show up here as they happen.",
1775
+ )}
1776
+ ${
1777
+ calls.length
1778
+ ? `${metricGrid([
1779
+ { label: "Calls", value: String(calls.length) },
1780
+ { label: "Total time", value: ms(totalMs), tone: grade(totalMs, [300, 1200]), ratio: ratio(totalMs, 1200) },
1781
+ {
1782
+ label: "Avg",
1783
+ value: ms(totalMs / calls.length),
1784
+ tone: grade(totalMs / calls.length, [150, 500]),
1785
+ },
1786
+ {
1787
+ label: "Failed",
1788
+ value: String(failed.length),
1789
+ tone: failed.length ? "bad" : "good",
1790
+ },
1791
+ { label: "Hosts", value: String(byHost.size) },
1792
+ { label: "Browser", value: String(clientApi.length) },
1793
+ { label: "SSR", value: String(calls.length - clientApi.length) },
1794
+ ])}
1795
+ ${
1796
+ hosts.length
1797
+ ? `<h4>By host</h4>
1798
+ <div class="rows">${hosts
1799
+ .map(
1800
+ ([host, info]) => `<div class="row">
1801
+ <span class="tag">${info.count}×</span>
1802
+ <span class="path">${escapeHtml(host)}</span>
1803
+ <span class="hint">${Math.round((info.ms / Math.max(totalMs, 1)) * 100)}%</span>
1804
+ <span class="${grade(info.ms / info.count, [150, 500])}">${ms(info.ms)}</span>
1805
+ </div>`,
1806
+ )
1807
+ .join("")}</div>`
1808
+ : ""
1809
+ }
1810
+ <h4>Calls<span class="head-action">${copyButton("api", "copy all")}</span></h4>
1811
+ ${
1812
+ selected
1813
+ ? apiDetailPanel(selected)
1814
+ : `<div class="empty" style="margin-bottom:8px">Select a call to inspect headers, timing and the response body when available.</div>`
1815
+ }
1816
+ <div class="rows">${calls
1817
+ .map((call) => {
1818
+ const bad = call.error || call.status >= 400 || call.status === 0;
1819
+ const warn = !bad && call.status >= 300;
1820
+ return `<div class="row clickable ${selectedApiKey === call.key ? "selected" : ""}" data-action="api-select" data-key="${escapeHtml(call.key)}">
1821
+ <span class="status ${bad ? "bad" : warn ? "warn" : ""}">${call.status || "—"}</span>
1822
+ <span class="method">${escapeHtml(call.method)}</span>
1823
+ <span class="path" title="${escapeHtml(call.url)}">${escapeHtml(shortUrl(call.url))}</span>
1824
+ <span class="hint">${escapeHtml(call.side)}${call.island ? ` · ${escapeHtml(call.island)}` : ""}</span>
1825
+ <span class="${grade(call.ms, [150, 500])}">${call.ms} ms</span>
1826
+ </div>`;
1827
+ })
1828
+ .join("")}</div>`
1829
+ : ""
1830
+ }
1831
+ `;
1832
+ }
1833
+
1834
+ /**
1835
+ * @param {ReturnType<typeof pageApiCalls>[number]} call
1836
+ */
1837
+ function apiDetailPanel(call) {
1838
+ const detailKey = `api-detail:${call.key}`;
1839
+ clipboard.set(
1840
+ detailKey,
1841
+ [
1842
+ `${call.method} ${call.url}`,
1843
+ `status: ${call.status}`,
1844
+ `ms: ${call.ms}`,
1845
+ `bytes: ${call.bytes}`,
1846
+ `side: ${call.side}`,
1847
+ call.host ? `host: ${call.host}` : "",
1848
+ call.page ? `page: ${call.page}` : "",
1849
+ call.island ? `island: ${call.island}` : "",
1850
+ call.contentType ? `content-type: ${call.contentType}` : "",
1851
+ call.error ? `error: ${call.error}` : "",
1852
+ call.details == null
1853
+ ? ""
1854
+ : typeof call.details === "string"
1855
+ ? call.details
1856
+ : safeJson(call.details),
1857
+ ]
1858
+ .filter(Boolean)
1859
+ .join("\n"),
1860
+ );
1861
+
1862
+ const detailsText = formatDetails(call.details);
1863
+
1864
+ return `<div class="api-detail">
1865
+ <div class="meta" style="display:flex;align-items:center;gap:8px;margin-bottom:10px;color:var(--muted);font-size:10.5px">
1866
+ <span class="tag">${escapeHtml(call.side)}</span>
1867
+ <span>${new Date(call.at).toLocaleTimeString()}</span>
1868
+ <span class="spacer"></span>
1869
+ ${copyButton(detailKey, "copy")}
1870
+ <button class="link" data-action="api-select" data-key="${escapeHtml(call.key)}" style="margin:0">close</button>
1871
+ </div>
1872
+ <dl class="kv">
1873
+ <dt>Method</dt><dd>${escapeHtml(call.method)}</dd>
1874
+ <dt>URL</dt><dd>${escapeHtml(call.url)}</dd>
1875
+ <dt>Host</dt><dd>${escapeHtml(call.host)}</dd>
1876
+ <dt>Status</dt><dd class="${call.error || call.status >= 400 || call.status === 0 ? "bad" : call.status >= 300 ? "mid" : "good"}">${call.status || "—"}</dd>
1877
+ <dt>Duration</dt><dd class="${grade(call.ms, [150, 500])}">${call.ms} ms</dd>
1878
+ <dt>Size</dt><dd>${call.bytes ? kb(call.bytes) : "—"}</dd>
1879
+ ${call.contentType ? `<dt>Type</dt><dd>${escapeHtml(call.contentType)}</dd>` : ""}
1880
+ ${call.page ? `<dt>Page</dt><dd>${pathLink(call.page)}</dd>` : ""}
1881
+ ${call.island ? `<dt>Island</dt><dd>${escapeHtml(call.island)}</dd>` : ""}
1882
+ ${call.error ? `<dt>Error</dt><dd class="bad">${escapeHtml(call.error)}</dd>` : ""}
1883
+ </dl>
1884
+ ${
1885
+ detailsText
1886
+ ? `<h4 style="margin:0 0 8px">Response body</h4><pre>${escapeHtml(detailsText)}</pre>`
1887
+ : `<div class="hint">No response body captured${call.side === "client" && !call.error ? " (successful browser calls keep the body for the page)" : ""}.</div>`
1888
+ }
1889
+ </div>`;
1890
+ }
1891
+
1575
1892
  function serverTab() {
1576
1893
  if (!serverStats) return `<div class="empty">Waiting for server stats…</div>`;
1577
1894
 
@@ -1807,7 +2124,7 @@ const ABOUT = {
1807
2124
  <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
2125
  <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
2126
  <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>
2127
+ <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
2128
  </div>
1812
2129
  <div class="cards">
1813
2130
  <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 +2443,16 @@ function paint() {
2126
2443
  versionChip.hidden = !version?.outdated;
2127
2444
  if (version?.outdated) versionChip.title = `JSkelet ${version.latest} is available`;
2128
2445
 
2446
+ const apiCalls = pageApiCalls();
2447
+ const apiChip = root.querySelector("[data-part='api-chip']");
2448
+ apiChip.hidden = !apiCalls.length;
2449
+ apiChip.className = `chip ${
2450
+ apiCalls.some((call) => call.error || call.status >= 400 || call.status === 0)
2451
+ ? ""
2452
+ : "neutral"
2453
+ }`;
2454
+ apiChip.textContent = String(apiCalls.length);
2455
+
2129
2456
  const warm = serverStats?.prewarm;
2130
2457
  const warmChip = root.querySelector("[data-part='warm-chip']");
2131
2458
  warmChip.hidden = !warm?.total;
@@ -2149,11 +2476,13 @@ function paint() {
2149
2476
  ? seoTab()
2150
2477
  : activeTab === "perf"
2151
2478
  ? perfTab()
2152
- : activeTab === "about"
2153
- ? aboutTab()
2154
- : activeTab === "warm"
2155
- ? warmTab()
2156
- : serverTab();
2479
+ : activeTab === "api"
2480
+ ? apiTab()
2481
+ : activeTab === "about"
2482
+ ? aboutTab()
2483
+ : activeTab === "warm"
2484
+ ? warmTab()
2485
+ : serverTab();
2157
2486
 
2158
2487
  if (html !== lastBody) {
2159
2488
  const body = root.querySelector(".body");
@@ -2238,6 +2567,14 @@ function bind(root) {
2238
2567
  return;
2239
2568
  }
2240
2569
 
2570
+ if (target.dataset.action === "api-select") {
2571
+ const key = target.dataset.key ?? null;
2572
+ selectedApiKey = selectedApiKey === key ? null : key;
2573
+ lastBody = "";
2574
+ render();
2575
+ return;
2576
+ }
2577
+
2241
2578
  if (target.dataset.action === "toggle") {
2242
2579
  setOpen(!open);
2243
2580
  return;
@@ -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();
@@ -121,13 +121,19 @@ const DETAILS_MAX = 4_000;
121
121
  * overlay hata günlüğüne de düşer — uygulama kendi logger'ıyla stderr'e
122
122
  * yazsa bile panel "hangi sayfa hangi API" bilgisini görsün.
123
123
  *
124
- * @param {{ onFailure?: (call: ApiFailure) => void }} [options]
124
+ * Her dış çağrı (başarılı ya da değil) `onCall` ile de bildirilir; overlay
125
+ * API sekmesi canlı kanal üzerinden güncellensin diye.
126
+ *
127
+ * @param {{
128
+ * onFailure?: (call: ApiFailure) => void,
129
+ * onCall?: (call: ApiFailure) => void,
130
+ * }} [options]
125
131
  */
126
132
  export function trackServerFetch(options = {}) {
127
133
  const original = globalThis.fetch;
128
134
  if (/** @type {any} */ (original).__jskeletWrapped) return;
129
135
 
130
- const { onFailure } = options;
136
+ const { onFailure, onCall } = options;
131
137
 
132
138
  /** @type {typeof fetch} */
133
139
  const wrapped = async (input, init) => {
@@ -157,6 +163,7 @@ export function trackServerFetch(options = {}) {
157
163
  details,
158
164
  };
159
165
  push(call);
166
+ onCall?.(call);
160
167
  if (error) onFailure?.(call);
161
168
  }
162
169
  return response;
@@ -174,6 +181,7 @@ export function trackServerFetch(options = {}) {
174
181
  details: null,
175
182
  };
176
183
  push(call);
184
+ onCall?.(call);
177
185
  onFailure?.(call);
178
186
  }
179
187
  throw error;
@@ -184,6 +192,16 @@ export function trackServerFetch(options = {}) {
184
192
  globalThis.fetch = wrapped;
185
193
  }
186
194
 
195
+ /**
196
+ * Overlay ve istatistik uçları için son dış çağrıları döner (yeniden eskiye).
197
+ *
198
+ * @param {number} [limit]
199
+ * @returns {ServerApiCall[]}
200
+ */
201
+ export function getServerApiCalls(limit = 50) {
202
+ return serverApiCalls.slice(-limit).reverse();
203
+ }
204
+
187
205
  /** @returns {string | null} */
188
206
  function currentPage() {
189
207
  return getRequestContext()?.pathname ?? null;
@@ -16,7 +16,9 @@ import process from "node:process";
16
16
  * sessionToken: string | null }} AwsCredentials
17
17
  */
18
18
 
19
- const METADATA_TIMEOUT_MS = 400;
19
+ // Local'de IMDS yokken hızlı vazgeç; ECS'te URI varken biraz daha sabır.
20
+ const IMDS_TIMEOUT_MS = 400;
21
+ const ECS_TIMEOUT_MS = 2_000;
20
22
  const ECS_METADATA_HOST = "http://169.254.170.2";
21
23
  const EC2_METADATA_HOST = "http://169.254.169.254";
22
24
 
@@ -72,7 +74,7 @@ function fromAwsCredentialJson(data) {
72
74
  * @param {number} [timeoutMs]
73
75
  * @returns {Promise<unknown | null>}
74
76
  */
75
- async function fetchJson(url, init = {}, timeoutMs = METADATA_TIMEOUT_MS) {
77
+ async function fetchJson(url, init = {}, timeoutMs = IMDS_TIMEOUT_MS) {
76
78
  const controller = new AbortController();
77
79
  const timer = setTimeout(() => controller.abort(), timeoutMs);
78
80
  try {
@@ -92,7 +94,7 @@ async function fetchJson(url, init = {}, timeoutMs = METADATA_TIMEOUT_MS) {
92
94
  * @param {number} [timeoutMs]
93
95
  * @returns {Promise<string | null>}
94
96
  */
95
- async function fetchText(url, init = {}, timeoutMs = METADATA_TIMEOUT_MS) {
97
+ async function fetchText(url, init = {}, timeoutMs = IMDS_TIMEOUT_MS) {
96
98
  const controller = new AbortController();
97
99
  const timer = setTimeout(() => controller.abort(), timeoutMs);
98
100
  try {
@@ -125,7 +127,7 @@ export async function readS3CredentialsFromEcs() {
125
127
  readTokenFile(process.env.AWS_CONTAINER_AUTHORIZATION_TOKEN_FILE);
126
128
  if (token) headers.Authorization = token;
127
129
 
128
- const data = await fetchJson(url, { headers });
130
+ const data = await fetchJson(url, { headers }, ECS_TIMEOUT_MS);
129
131
  if (!data || typeof data !== "object") return null;
130
132
  return fromAwsCredentialJson(/** @type {Record<string, unknown>} */ (data));
131
133
  }
@@ -179,6 +181,21 @@ export async function readS3CredentialsFromImds() {
179
181
  return fromAwsCredentialJson(/** @type {Record<string, unknown>} */ (data));
180
182
  }
181
183
 
184
+ /**
185
+ * Credential neden bulunamadı — upload skip satırı için.
186
+ *
187
+ * @returns {string}
188
+ */
189
+ export function s3CredentialsMissingReason() {
190
+ if (
191
+ envText(process.env.AWS_CONTAINER_CREDENTIALS_RELATIVE_URI) ||
192
+ envText(process.env.AWS_CONTAINER_CREDENTIALS_FULL_URI)
193
+ ) {
194
+ return "credentials missing (ECS metadata failed)";
195
+ }
196
+ return "credentials missing (no env keys, no ECS task role — upload needs runtime/CI role, not docker build)";
197
+ }
198
+
182
199
  /**
183
200
  * Env → ECS task role → EC2 instance profile.
184
201
  *
@@ -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(() => "");
@@ -76,11 +76,25 @@ export type ApiFailure = {
76
76
  * overlay hata günlüğüne de düşer — uygulama kendi logger'ıyla stderr'e
77
77
  * yazsa bile panel "hangi sayfa hangi API" bilgisini görsün.
78
78
  *
79
- * @param {{ onFailure?: (call: ApiFailure) => void }} [options]
79
+ * Her dış çağrı (başarılı ya da değil) `onCall` ile de bildirilir; overlay
80
+ * API sekmesi canlı kanal üzerinden güncellensin diye.
81
+ *
82
+ * @param {{
83
+ * onFailure?: (call: ApiFailure) => void,
84
+ * onCall?: (call: ApiFailure) => void,
85
+ * }} [options]
80
86
  */
81
87
  export declare function trackServerFetch(options?: {
82
88
  onFailure?: (call: ApiFailure) => void;
89
+ onCall?: (call: ApiFailure) => void;
83
90
  }): void;
91
+ /**
92
+ * Overlay ve istatistik uçları için son dış çağrıları döner (yeniden eskiye).
93
+ *
94
+ * @param {number} [limit]
95
+ * @returns {ServerApiCall[]}
96
+ */
97
+ export declare function getServerApiCalls(limit?: number): ServerApiCall[];
84
98
  /**
85
99
  * Rapor sayfasının tek veri kaynağı.
86
100
  * @param {{ requests: object[], errors: object[] }} devtools
@@ -19,6 +19,12 @@ export declare function readS3CredentialsFromEcs(): Promise<AwsCredentials | nul
19
19
  * @returns {Promise<AwsCredentials | null>}
20
20
  */
21
21
  export declare function readS3CredentialsFromImds(): Promise<AwsCredentials | null>;
22
+ /**
23
+ * Credential neden bulunamadı — upload skip satırı için.
24
+ *
25
+ * @returns {string}
26
+ */
27
+ export declare function s3CredentialsMissingReason(): string;
22
28
  /**
23
29
  * Env → ECS task role → EC2 instance profile.
24
30
  *
@@ -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
  *