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 +15 -0
- package/docs/07-yapilandirma.md +1 -1
- package/docs/09-dev-araclari.md +6 -0
- package/docs/en/07-configuration.md +1 -1
- package/docs/en/09-dev-tools.md +6 -0
- package/package.json +1 -1
- package/src/client/devtools/overlay.js +446 -26
- package/src/server/dev/devtools.js +9 -1
- package/src/server/dev/report.js +48 -2
- package/src/server/logs/s3-put.js +52 -15
- package/types/server/dev/report.d.ts +18 -1
- package/types/server/logs/s3-put.d.ts +11 -0
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.
|
package/docs/07-yapilandirma.md
CHANGED
|
@@ -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. |
|
package/docs/09-dev-araclari.md
CHANGED
|
@@ -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. |
|
package/docs/en/09-dev-tools.md
CHANGED
|
@@ -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.
|
|
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
|
-
/**
|
|
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:
|
|
411
|
+
island: entry.island,
|
|
366
412
|
stack: error instanceof Error ? error.stack : null,
|
|
367
|
-
details:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
472
|
-
*
|
|
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
|
|
549
|
+
function inferComponent() {
|
|
477
550
|
const mounting = globalThis.__JSKELET_MOUNTING_ISLAND;
|
|
478
551
|
if (typeof mounting === "string" && mounting) return mounting;
|
|
479
552
|
|
|
480
|
-
const
|
|
481
|
-
|
|
482
|
-
|
|
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
|
|
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 === "
|
|
2153
|
-
?
|
|
2154
|
-
: activeTab === "
|
|
2155
|
-
?
|
|
2156
|
-
:
|
|
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({
|
|
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();
|
package/src/server/dev/report.js
CHANGED
|
@@ -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
|
-
*
|
|
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
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
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 =
|
|
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
|
|
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
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
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
|
-
*
|
|
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
|
*
|