jskelet 0.2.4 → 0.2.5

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.
Files changed (91) hide show
  1. package/AGENTS.md +132 -132
  2. package/CHANGELOG.md +8 -0
  3. package/LICENSE +21 -21
  4. package/bin/jskelet.mjs +103 -103
  5. package/docs/01-baslangic.md +285 -285
  6. package/docs/02-mimari.md +287 -287
  7. package/docs/03-routing.md +480 -480
  8. package/docs/04-render-ve-sablonlar.md +490 -490
  9. package/docs/05-islands.md +482 -482
  10. package/docs/06-cache.md +1209 -1209
  11. package/docs/08-build.md +366 -366
  12. package/docs/09-dev-araclari.md +335 -335
  13. package/docs/10-dagitim.md +329 -329
  14. package/docs/12-panel-ve-oturum.md +384 -384
  15. package/docs/README.md +105 -105
  16. package/docs/en/01-getting-started.md +292 -292
  17. package/docs/en/02-architecture.md +305 -305
  18. package/docs/en/03-routing.md +497 -497
  19. package/docs/en/04-rendering.md +504 -504
  20. package/docs/en/05-islands.md +492 -492
  21. package/docs/en/06-caching.md +1239 -1239
  22. package/docs/en/07-configuration.md +986 -986
  23. package/docs/en/08-build.md +383 -383
  24. package/docs/en/09-dev-tools.md +342 -342
  25. package/docs/en/10-deployment.md +332 -332
  26. package/docs/en/11-migration.md +359 -359
  27. package/docs/en/12-dashboards-and-sessions.md +392 -392
  28. package/docs/en/README.md +112 -112
  29. package/package.json +102 -102
  30. package/src/build/ensure-build.mjs +15 -15
  31. package/src/build/paths.mjs +143 -143
  32. package/src/build/resolve-peer.mjs +36 -36
  33. package/src/build/tasks/client.mjs +268 -268
  34. package/src/build/tasks/css.mjs +124 -124
  35. package/src/build/tasks/fonts.mjs +146 -146
  36. package/src/build/tasks/icons.mjs +224 -224
  37. package/src/build/tasks/images.mjs +244 -244
  38. package/src/build/tasks/precompress.mjs +78 -78
  39. package/src/client/cache-panel/i18n.js +670 -670
  40. package/src/client/cache-panel/login.html +74 -74
  41. package/src/client/cache-panel/panel.css +756 -756
  42. package/src/client/cache-panel/panel.html +308 -308
  43. package/src/client/cache-panel/panel.js +915 -915
  44. package/src/client/devtools/report.html +185 -185
  45. package/src/client/devtools/report.js +725 -725
  46. package/src/client/dom.js +95 -95
  47. package/src/client/form.js +192 -192
  48. package/src/client/index.js +35 -35
  49. package/src/client/registry.js +297 -297
  50. package/src/client/safe-image.js +91 -91
  51. package/src/client/store.js +36 -36
  52. package/src/client/swap.js +188 -188
  53. package/src/config/pattern.js +107 -107
  54. package/src/http/control-flow.js +71 -71
  55. package/src/http/cookies.js +257 -257
  56. package/src/http/request-cache.js +46 -46
  57. package/src/http/request-context.js +162 -162
  58. package/src/index.js +83 -83
  59. package/src/init.mjs +221 -221
  60. package/src/runtime/alias-hooks.mjs +119 -119
  61. package/src/runtime/register.mjs +4 -4
  62. package/src/server/assets.js +147 -147
  63. package/src/server/cache-deps.js +42 -42
  64. package/src/server/cache-panel.js +759 -759
  65. package/src/server/cloudflare.js +607 -595
  66. package/src/server/create-app.js +291 -291
  67. package/src/server/data-cache.js +462 -462
  68. package/src/server/dev/report.js +369 -369
  69. package/src/server/dev/socket.js +170 -170
  70. package/src/server/dev/version-check.mjs +139 -139
  71. package/src/server/html-cache.js +817 -817
  72. package/src/server/metadata.js +102 -102
  73. package/src/server/middleware/compression.js +205 -205
  74. package/src/server/middleware/csrf.js +134 -134
  75. package/src/server/middleware/dev-gate.js +62 -62
  76. package/src/server/middleware/headers.js +37 -37
  77. package/src/server/middleware/redirects.js +32 -32
  78. package/src/server/middleware/static-precompressed.js +100 -100
  79. package/src/server/middleware/upstream-proxy.js +141 -141
  80. package/src/server/prewarm.js +601 -601
  81. package/src/server/redis.js +569 -569
  82. package/src/server/router.js +128 -128
  83. package/src/server/status-page.js +164 -164
  84. package/src/server/upstream-limiter.js +376 -376
  85. package/src/server/upstream-tracking.js +166 -166
  86. package/src/start.mjs +7 -7
  87. package/src/templates/layout.ejs +44 -44
  88. package/src/version.mjs +31 -31
  89. package/src/views/components/loader.js +85 -85
  90. package/src/views/helpers/html.js +102 -102
  91. package/src/views/helpers/tags.js +245 -245
@@ -1,725 +1,725 @@
1
- /**
2
- * "Inspect in detail" sayfasının arayüzü.
3
- *
4
- * Overlay ile aynı mantık: build'e girmez, dev sunucusu bu dosyayı ham olarak
5
- * servis eder. Tüm veri tek bir uçtan gelir (`/__jskelet/dev/report/data`),
6
- * sayfa yalnızca onu görselleştirir.
7
- */
8
-
9
- const BASE = "/__jskelet/dev";
10
-
11
- /** @type {any} */
12
- let data = null;
13
- let activeTab = "overview";
14
- let auto = false;
15
- /** @type {number | null} */
16
- let timer = null;
17
-
18
- /** Tablo sıralamaları: tabloId → { key, dir }. */
19
- const sorting = new Map();
20
-
21
- /** Arama kutuları: tabloId → metin. */
22
- const filters = new Map();
23
-
24
- /** Chunk analizinde seçili çıktı. */
25
- let selectedChunk = null;
26
-
27
- const TABS = [
28
- { id: "overview", label: "Overview" },
29
- { id: "pages", label: "Pages" },
30
- { id: "chunks", label: "Chunk analysis" },
31
- { id: "assets", label: "Assets" },
32
- { id: "ssr", label: "SSR & CSR" },
33
- { id: "api", label: "API requests" },
34
- { id: "errors", label: "Errors" },
35
- ];
36
-
37
- /* ------------------------------------------------------------- yardımcılar */
38
-
39
- /** @param {number | null | undefined} bytes */
40
- function size(bytes) {
41
- if (bytes == null) return "—";
42
- if (bytes >= 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(2)} MB`;
43
- if (bytes >= 1024) return `${Math.round(bytes / 1024)} KB`;
44
- return `${bytes} B`;
45
- }
46
-
47
- /** @param {number | null | undefined} value */
48
- function ms(value) {
49
- return value == null ? "—" : `${Math.round(value)} ms`;
50
- }
51
-
52
- /**
53
- * @param {number | null | undefined} value
54
- * @param {[number, number]} bounds iyi/orta sınırı
55
- */
56
- function tone(value, [good, mid]) {
57
- if (value == null) return "";
58
- if (value <= good) return "good";
59
- if (value <= mid) return "mid";
60
- return "bad";
61
- }
62
-
63
- /** @param {string} value */
64
- function escapeHtml(value) {
65
- return String(value ?? "").replace(
66
- /[&<>"]/g,
67
- (char) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" })[char],
68
- );
69
- }
70
-
71
- /** @param {string} url */
72
- function link(url) {
73
- const text = escapeHtml(url);
74
- if (!url?.startsWith("/")) return text;
75
- return `<a href="${text}" target="_blank" rel="noreferrer">${text}</a>`;
76
- }
77
-
78
- /**
79
- * @param {{ label: string, value: string, sub?: string, tone?: string, ratio?: number | null }[]} items
80
- */
81
- function metrics(items) {
82
- return `<div class="grid">${items
83
- .map((item) => {
84
- const width = Math.round(Math.min(1, Math.max(0.03, item.ratio ?? 0)) * 100);
85
- return `<div class="metric">
86
- <div class="label">${escapeHtml(item.label)}</div>
87
- <div class="value ${item.tone ?? ""}">${escapeHtml(item.value)}</div>
88
- ${item.sub ? `<div class="sub">${item.sub}</div>` : ""}
89
- ${
90
- item.ratio == null
91
- ? ""
92
- : `<div class="bar"><span class="${item.tone ?? ""}" style="width:${width}%"></span></div>`
93
- }
94
- </div>`;
95
- })
96
- .join("")}</div>`;
97
- }
98
-
99
- /**
100
- * Sıralanabilir, aranabilir tablo.
101
- *
102
- * @param {string} id
103
- * @param {{ key: string, label: string, value?: (row: any) => any, render?: (row: any) => string }[]} columns
104
- * @param {any[]} rows
105
- * @param {{ search?: boolean, rowAttrs?: (row: any) => string }} [options]
106
- */
107
- function table(id, columns, rows, options = {}) {
108
- const sort = sorting.get(id) ?? { key: columns[0].key, dir: "desc" };
109
- const query = (filters.get(id) ?? "").trim().toLowerCase();
110
-
111
- const valueOf = (row, column) =>
112
- column.value ? column.value(row) : row[column.key];
113
-
114
- let visible = rows;
115
- if (query) {
116
- visible = rows.filter((row) =>
117
- columns.some((column) =>
118
- String(valueOf(row, column) ?? "")
119
- .toLowerCase()
120
- .includes(query),
121
- ),
122
- );
123
- }
124
-
125
- const column = columns.find((item) => item.key === sort.key) ?? columns[0];
126
- visible = [...visible].sort((a, b) => {
127
- const left = valueOf(a, column);
128
- const right = valueOf(b, column);
129
- const compare =
130
- typeof left === "number" && typeof right === "number"
131
- ? left - right
132
- : String(left ?? "").localeCompare(String(right ?? ""));
133
- return sort.dir === "asc" ? compare : -compare;
134
- });
135
-
136
- const search = options.search
137
- ? `<div class="toolbar">
138
- <input type="search" data-filter="${id}" value="${escapeHtml(filters.get(id) ?? "")}"
139
- placeholder="Filter — ${visible.length}/${rows.length} rows" />
140
- </div>`
141
- : "";
142
-
143
- if (!rows.length) {
144
- return `${search}<div class="empty">No records.</div>`;
145
- }
146
-
147
- return `${search}
148
- <div class="table-wrap">
149
- <table data-table="${id}">
150
- <thead><tr>${columns
151
- .map(
152
- (item) =>
153
- `<th data-sort="${item.key}" ${
154
- item.key === sort.key ? `data-dir="${sort.dir}"` : ""
155
- }>${escapeHtml(item.label)}</th>`,
156
- )
157
- .join("")}</tr></thead>
158
- <tbody>${visible
159
- .map(
160
- (row) =>
161
- `<tr ${options.rowAttrs?.(row) ?? ""}>${columns
162
- .map(
163
- (item) =>
164
- `<td class="${item.key === columns[0].key ? "path" : ""}">${
165
- item.render ? item.render(row) : escapeHtml(valueOf(row, item) ?? "—")
166
- }</td>`,
167
- )
168
- .join("")}</tr>`,
169
- )
170
- .join("")}</tbody>
171
- </table>
172
- </div>`;
173
- }
174
-
175
- /* ------------------------------------------------------------- sekmeler */
176
-
177
- function overview() {
178
- const pages = data.pages ?? [];
179
- const measured = pages.filter((page) => page.metrics?.lcp != null);
180
- const lcps = measured.map((page) => page.metrics.lcp);
181
- const worst = [...measured].sort((a, b) => b.metrics.lcp - a.metrics.lcp).slice(0, 8);
182
-
183
- const average = lcps.length ? lcps.reduce((sum, value) => sum + value, 0) / lcps.length : null;
184
- const js = (data.build?.outputs ?? []).filter((item) => item.file.endsWith(".js"));
185
- const jsBytes = js.reduce((sum, item) => sum + item.bytes, 0);
186
- const css = (data.build?.assets ?? []).filter((item) => item.kind === "css");
187
- const cssBytes = css.reduce((sum, item) => sum + (item.bytes ?? 0), 0);
188
- const ssrBytes = pages.reduce((sum, page) => sum + (page.html?.bytes ?? 0), 0);
189
- const apiErrors = (data.serverApi ?? []).filter((call) => call.error).length;
190
- const islandTotal = pages.reduce((sum, page) => sum + (page.islands?.total ?? 0), 0);
191
- const islandReady = pages.reduce((sum, page) => sum + (page.islands?.ready ?? 0), 0);
192
-
193
- return `
194
- <h2>Site wide<span class="note">${pages.length} pages known, ${measured.length} of them measured in the browser</span></h2>
195
- ${metrics([
196
- {
197
- label: "Average LCP",
198
- value: ms(average),
199
- tone: tone(average, [2500, 4000]),
200
- ratio: average == null ? null : average / 4000,
201
- sub: `worst ${ms(worst[0]?.metrics?.lcp)}`,
202
- },
203
- {
204
- label: "Measured pages",
205
- value: `${measured.length}/${pages.length}`,
206
- ratio: pages.length ? measured.length / pages.length : null,
207
- tone: "good",
208
- sub: "browse to measure",
209
- },
210
- { label: "JS output", value: size(jsBytes), sub: `${js.length} files` },
211
- { label: "CSS output", value: size(cssBytes), sub: `${css.length} files` },
212
- { label: "SSR HTML", value: size(ssrBytes), sub: "total of known pages" },
213
- {
214
- label: "Island (CSR)",
215
- value: `${islandReady}/${islandTotal}`,
216
- ratio: islandTotal ? islandReady / islandTotal : null,
217
- tone: "good",
218
- sub: "mounted / marked",
219
- },
220
- {
221
- label: "HTML cache",
222
- value: String(data.cache?.size ?? 0),
223
- sub: `${size((data.cache?.entries ?? []).reduce((sum, item) => sum + item.bytes, 0))} in memory`,
224
- },
225
- {
226
- label: "Server API calls",
227
- value: String((data.serverApi ?? []).length),
228
- tone: apiErrors ? "bad" : "good",
229
- sub: apiErrors ? `${apiErrors} errors` : "no errors",
230
- },
231
- // Upstream kotasının nasıl harcandığı: okumaların kaçı önbellekten
232
- // döndü. Oran düşükse `withDataCache` TTL'i kısa demektir.
233
- {
234
- label: "Data cache",
235
- value: data.cache?.dataStats?.reads
236
- ? `${Math.round(data.cache.dataStats.hitRatio * 100)}%`
237
- : "—",
238
- ratio: data.cache?.dataStats?.hitRatio ?? null,
239
- tone: "good",
240
- sub: data.cache?.dataStats?.reads
241
- ? `${data.cache.dataStats.produced} upstream / ${data.cache.dataStats.reads} reads`
242
- : "no reads yet",
243
- },
244
- {
245
- label: "Prewarm",
246
- value: data.prewarm?.total
247
- ? `${data.prewarm.ok}/${data.prewarm.total}`
248
- : "—",
249
- ratio: data.prewarm?.total ? data.prewarm.done / data.prewarm.total : null,
250
- tone: data.prewarm?.failed ? "mid" : "good",
251
- },
252
- {
253
- label: "Process",
254
- value: size(data.process?.memory?.rss),
255
- sub: `${data.process?.node} · ${Math.round(data.process?.uptime ?? 0)} s`,
256
- },
257
- ])}
258
-
259
- <h2>Slowest pages<span class="note">by LCP</span></h2>
260
- ${
261
- worst.length
262
- ? table(
263
- "worst",
264
- [
265
- { key: "url", label: "Page", render: (row) => link(row.url) },
266
- { key: "lcp", label: "LCP", value: (row) => row.metrics.lcp, render: (row) => `<span class="${tone(row.metrics.lcp, [2500, 4000])}">${ms(row.metrics.lcp)}</span>` },
267
- { key: "cls", label: "CLS", value: (row) => row.metrics.cls ?? 0, render: (row) => (row.metrics.cls ?? 0).toFixed(3) },
268
- { key: "inp", label: "INP", value: (row) => row.metrics.inp ?? 0, render: (row) => ms(row.metrics.inp) },
269
- { key: "transfer", label: "Transfer", value: (row) => row.resources?.bytes ?? 0, render: (row) => size(row.resources?.bytes) },
270
- { key: "html", label: "SSR HTML", value: (row) => row.html?.bytes ?? 0, render: (row) => size(row.html?.bytes) },
271
- ],
272
- worst,
273
- )
274
- : `<div class="empty">No browser measurements yet. Pages land here as you browse the site.</div>`
275
- }
276
-
277
- <h2>Web Vitals distribution</h2>
278
- <div class="card">
279
- ${["lcp", "fcp", "ttfb", "inp"]
280
- .map((key) => {
281
- const values = measured
282
- .map((page) => page.metrics[key])
283
- .filter((value) => value != null)
284
- .sort((a, b) => a - b);
285
- if (!values.length) return "";
286
- const p = (ratio) => values[Math.min(values.length - 1, Math.floor(values.length * ratio))];
287
- const bounds = { lcp: [2500, 4000], fcp: [1800, 3000], ttfb: [800, 1800], inp: [200, 500] }[key];
288
- return `<div class="barrow">
289
- <span class="name">${key.toUpperCase()} — median ${ms(p(0.5))}, p75 ${ms(p(0.75))}, p95 ${ms(p(0.95))}</span>
290
- <span class="size ${tone(p(0.75), bounds)}">${ms(p(0.75))}</span>
291
- <span class="track"><span class="${tone(p(0.75), bounds)}" style="width:${Math.min(100, (p(0.75) / (bounds[1] * 1.5)) * 100)}%"></span></span>
292
- </div>`;
293
- })
294
- .join("")}
295
- </div>
296
- `;
297
- }
298
-
299
- function pagesTab() {
300
- const rows = data.pages ?? [];
301
-
302
- return `
303
- <h2>Pages<span class="note">browser measurements build up as you browse; SSR columns come from the prewarm round</span></h2>
304
- ${table(
305
- "pages",
306
- [
307
- { key: "url", label: "Page", render: (row) => link(row.url) },
308
- { key: "visits", label: "Visits", value: (row) => row.visits },
309
- { key: "lcp", label: "LCP", value: (row) => row.metrics?.lcp ?? -1, render: (row) => `<span class="${tone(row.metrics?.lcp, [2500, 4000])}">${ms(row.metrics?.lcp)}</span>` },
310
- { key: "cls", label: "CLS", value: (row) => row.metrics?.cls ?? -1, render: (row) => (row.metrics?.cls == null ? "—" : `<span class="${tone(row.metrics.cls * 1000, [100, 250])}">${row.metrics.cls.toFixed(3)}</span>`) },
311
- { key: "inp", label: "INP", value: (row) => row.metrics?.inp ?? -1, render: (row) => `<span class="${tone(row.metrics?.inp, [200, 500])}">${ms(row.metrics?.inp)}</span>` },
312
- { key: "fcp", label: "FCP", value: (row) => row.metrics?.fcp ?? -1, render: (row) => ms(row.metrics?.fcp) },
313
- { key: "ttfb", label: "TTFB", value: (row) => row.metrics?.ttfb ?? -1, render: (row) => `<span class="${tone(row.metrics?.ttfb, [800, 1800])}">${ms(row.metrics?.ttfb)}</span>` },
314
- { key: "blocking", label: "Blocking", value: (row) => row.metrics?.blocking ?? -1, render: (row) => ms(row.metrics?.blocking) },
315
- { key: "requests", label: "Requests", value: (row) => row.resources?.count ?? 0 },
316
- { key: "transfer", label: "Transfer", value: (row) => row.resources?.bytes ?? 0, render: (row) => size(row.resources?.bytes) },
317
- { key: "islands", label: "Island", value: (row) => row.islands?.total ?? 0, render: (row) => (row.islands?.total ? `${row.islands.ready}/${row.islands.total}` : "—") },
318
- { key: "api", label: "API", value: (row) => row.api?.length ?? 0 },
319
- { key: "html", label: "SSR HTML", value: (row) => row.html?.bytes ?? 0, render: (row) => size(row.html?.bytes) },
320
- { key: "render", label: "Render", value: (row) => row.html?.ms ?? -1, render: (row) => ms(row.html?.ms) },
321
- { key: "cache", label: "Cache", render: (row) => (row.html?.cache ? `<span class="tag">${escapeHtml(row.html.cache)}</span>` : "—") },
322
- ],
323
- rows,
324
- { search: true },
325
- )}
326
- `;
327
- }
328
-
329
- function chunksTab() {
330
- const build = data.build ?? {};
331
- if (!build.available) {
332
- return `<div class="empty">Chunk analysis needs <code>build/generated/metafile.json</code>. It appears once <code>npm run dev</code> or <code>npm run build</code> has completed a round.</div>`;
333
- }
334
-
335
- const outputs = build.outputs ?? [];
336
- const selected =
337
- outputs.find((item) => item.file === selectedChunk) ?? outputs[0];
338
- const heaviest = outputs[0]?.bytes ?? 1;
339
- const totalBytes = outputs.reduce((sum, item) => sum + item.bytes, 0);
340
- const groupTotal = (build.groups ?? []).reduce((sum, item) => sum + item.bytes, 0) || 1;
341
-
342
- return `
343
- <h2>Outputs<span class="note">${outputs.length} files · ${size(totalBytes)} raw</span></h2>
344
- <div class="split">
345
- <div>
346
- ${table(
347
- "chunks",
348
- [
349
- {
350
- key: "file",
351
- label: "File",
352
- render: (row) =>
353
- `${link(row.file)} <span class="tag ${row.isChunk ? "chunk" : "entry"}">${row.isChunk ? "chunk" : "entry"}</span>`,
354
- },
355
- { key: "bytes", label: "Raw", value: (row) => row.bytes, render: (row) => size(row.bytes) },
356
- { key: "gzip", label: "Gzip", value: (row) => row.gzip ?? 0, render: (row) => size(row.gzip) },
357
- { key: "brotli", label: "Brotli", value: (row) => row.brotli ?? 0, render: (row) => size(row.brotli) },
358
- { key: "inputCount", label: "Modules", value: (row) => row.inputCount },
359
- { key: "imports", label: "Imports", value: (row) => row.imports.length },
360
- {
361
- key: "share",
362
- label: "Share",
363
- value: (row) => row.bytes / heaviest,
364
- render: (row) => `${Math.round((row.bytes / totalBytes) * 100)}%`,
365
- },
366
- ],
367
- outputs,
368
- {
369
- search: true,
370
- rowAttrs: (row) =>
371
- `class="clickable ${row.file === selected?.file ? "selected" : ""}" data-chunk="${escapeHtml(row.file)}"`,
372
- },
373
- )}
374
- </div>
375
- <div>
376
- <div class="card">
377
- <h2 style="margin-top:0">Selected output</h2>
378
- ${
379
- selected
380
- ? `<div class="hint" style="margin-bottom:10px">
381
- <code>${escapeHtml(selected.file)}</code><br />
382
- ${size(selected.bytes)} raw · ${size(selected.gzip)} gzip · ${selected.inputCount} modules
383
- ${selected.entry ? `<br />entry: <code>${escapeHtml(selected.entry)}</code>` : ""}
384
- </div>
385
- <div class="bars">
386
- ${selected.inputs
387
- .map(
388
- (input) => `<div class="barrow">
389
- <span class="name" title="${escapeHtml(input.source)}">${escapeHtml(input.source)}</span>
390
- <span class="size">${size(input.bytes)}</span>
391
- <span class="track"><span style="width:${Math.round((input.bytes / selected.inputs[0].bytes) * 100)}%"></span></span>
392
- </div>`,
393
- )
394
- .join("")}
395
- </div>
396
- ${
397
- selected.imports.length
398
- ? `<h2>Chunks it imports</h2>
399
- <div class="hint">${selected.imports
400
- .map((item) => `<div><code>${escapeHtml(item.path)}</code> <span class="tag">${escapeHtml(item.kind)}</span></div>`)
401
- .join("")}</div>`
402
- : ""
403
- }`
404
- : `<div class="hint">Pick an output on the left.</div>`
405
- }
406
- </div>
407
- </div>
408
- </div>
409
-
410
- <h2>Source groups<span class="note">package / folder share across all outputs</span></h2>
411
- <div class="card bars">
412
- ${(build.groups ?? [])
413
- .slice(0, 25)
414
- .map(
415
- (group) => `<div class="barrow">
416
- <span class="name">${escapeHtml(group.name)}</span>
417
- <span class="size">${size(group.bytes)}</span>
418
- <span class="track"><span style="width:${Math.round((group.bytes / groupTotal) * 100)}%"></span></span>
419
- </div>`,
420
- )
421
- .join("")}
422
- </div>
423
- `;
424
- }
425
-
426
- function assetsTab() {
427
- const assets = data.build?.assets ?? [];
428
- const total = assets.reduce((sum, item) => sum + (item.bytes ?? 0), 0);
429
-
430
- return `
431
- <h2>Manifest assets<span class="note">${assets.length} entries · ${size(total)}</span></h2>
432
- ${table(
433
- "assets",
434
- [
435
- { key: "name", label: "Name" },
436
- { key: "url", label: "Path", render: (row) => link(row.url) },
437
- { key: "kind", label: "Kind", render: (row) => `<span class="tag">${escapeHtml(row.kind)}</span>` },
438
- { key: "bytes", label: "Raw", value: (row) => row.bytes ?? 0, render: (row) => size(row.bytes) },
439
- { key: "gzip", label: "Gzip", value: (row) => row.gzip ?? 0, render: (row) => size(row.gzip) },
440
- { key: "brotli", label: "Brotli", value: (row) => row.brotli ?? 0, render: (row) => size(row.brotli) },
441
- {
442
- key: "ratio",
443
- label: "Savings",
444
- value: (row) => (row.bytes && row.gzip ? 1 - row.gzip / row.bytes : 0),
445
- render: (row) =>
446
- row.bytes && row.gzip
447
- ? `${Math.round((1 - row.gzip / row.bytes) * 100)}%`
448
- : "—",
449
- },
450
- ],
451
- assets,
452
- { search: true },
453
- )}
454
- `;
455
- }
456
-
457
- function ssrTab() {
458
- const pages = data.pages ?? [];
459
- const cache = data.cache?.entries ?? [];
460
-
461
- /** @type {Map<string, { name: string, pages: number }>} */
462
- const islands = new Map();
463
- for (const page of pages) {
464
- for (const name of page.islands?.names ?? []) {
465
- const entry = islands.get(name) ?? { name, pages: 0 };
466
- entry.pages += 1;
467
- islands.set(name, entry);
468
- }
469
- }
470
-
471
- const csr = pages.filter((page) => (page.islands?.total ?? 0) > 0);
472
- const partial = csr.filter((page) => page.islands.ready < page.islands.total);
473
-
474
- return `
475
- <h2>Server render & client hydration</h2>
476
- ${metrics([
477
- { label: "SSR pages", value: String(pages.filter((page) => page.html?.bytes).length), sub: "HTML size known" },
478
- { label: "Pages with islands", value: `${csr.length}/${pages.length}`, ratio: pages.length ? csr.length / pages.length : null, tone: "good" },
479
- { label: "Partial hydration", value: String(partial.length), tone: partial.length ? "mid" : "good", sub: "some islands did not mount" },
480
- { label: "Distinct islands", value: String(islands.size) },
481
- { label: "Cache entries", value: String(data.cache?.size ?? 0) },
482
- { label: "Cache size", value: size(cache.reduce((sum, item) => sum + item.bytes, 0)) },
483
- ])}
484
-
485
- <h2>Island inventory<span class="note">islands seen on the pages you browsed</span></h2>
486
- ${table(
487
- "islands",
488
- [
489
- { key: "name", label: "Island" },
490
- { key: "pages", label: "Page count", value: (row) => row.pages },
491
- ],
492
- [...islands.values()],
493
- { search: true },
494
- )}
495
-
496
- <h2>HTML cache<span class="note">entries in memory, with time to stale</span></h2>
497
- ${table(
498
- "cache",
499
- [
500
- { key: "key", label: "Key" },
501
- { key: "status", label: "Status", render: (row) => `<span class="status ${row.status >= 400 ? "bad" : ""}">${row.status}</span>` },
502
- { key: "bytes", label: "HTML", value: (row) => row.bytes, render: (row) => size(row.bytes) },
503
- { key: "expiresIn", label: "Freshness", value: (row) => row.expiresIn, render: (row) => (row.stale ? `<span class="mid">stale</span>` : `${row.expiresIn} s`) },
504
- { key: "encodings", label: "Compression", render: (row) => (row.encodings.length ? row.encodings.map((item) => `<span class="tag">${escapeHtml(item)}</span>`).join(" ") : "—") },
505
- ],
506
- cache,
507
- { search: true },
508
- )}
509
- `;
510
- }
511
-
512
- function apiTab() {
513
- const server = data.serverApi ?? [];
514
-
515
- /** @type {Map<string, { key: string, host: string, calls: number, ms: number, errors: number, bytes: number }>} */
516
- const grouped = new Map();
517
- for (const call of server) {
518
- let route = call.url;
519
- try {
520
- route = new URL(call.url).pathname;
521
- } catch {
522
- // Göreli adres.
523
- }
524
- const key = `${call.host}${route}`;
525
- const entry = grouped.get(key) ?? { key: route, host: call.host, calls: 0, ms: 0, errors: 0, bytes: 0 };
526
- entry.calls += 1;
527
- entry.ms += call.ms;
528
- entry.errors += call.error ? 1 : 0;
529
- entry.bytes += call.bytes;
530
- grouped.set(key, entry);
531
- }
532
-
533
- const client = (data.pages ?? []).flatMap((page) =>
534
- (page.api ?? []).map((call) => ({ ...call, page: page.url })),
535
- );
536
-
537
- return `
538
- <h2>Server side calls<span class="note">outbound requests made during SSR, grouped by path</span></h2>
539
- ${table(
540
- "api-groups",
541
- [
542
- { key: "key", label: "Path" },
543
- { key: "host", label: "Host" },
544
- { key: "calls", label: "Calls", value: (row) => row.calls },
545
- { key: "avg", label: "Avg. time", value: (row) => row.ms / row.calls, render: (row) => `<span class="${tone(row.ms / row.calls, [200, 800])}">${ms(row.ms / row.calls)}</span>` },
546
- { key: "total", label: "Total time", value: (row) => row.ms, render: (row) => ms(row.ms) },
547
- { key: "bytes", label: "Body", value: (row) => row.bytes, render: (row) => size(row.bytes) },
548
- { key: "errors", label: "Errors", value: (row) => row.errors, render: (row) => (row.errors ? `<span class="bad">${row.errors}</span>` : "0") },
549
- ],
550
- [...grouped.values()],
551
- { search: true },
552
- )}
553
-
554
- <h2>Recent calls</h2>
555
- ${table(
556
- "api-calls",
557
- [
558
- { key: "url", label: "URL" },
559
- { key: "method", label: "Method" },
560
- { key: "status", label: "Status", render: (row) => `<span class="status ${row.error ? "bad" : ""}">${row.status || "—"}</span>` },
561
- { key: "ms", label: "Time", value: (row) => row.ms, render: (row) => `<span class="${tone(row.ms, [200, 800])}">${ms(row.ms)}</span>` },
562
- { key: "bytes", label: "Body", value: (row) => row.bytes, render: (row) => size(row.bytes) },
563
- { key: "error", label: "Error", render: (row) => (row.error ? `<span class="bad">${escapeHtml(row.error)}</span>` : "—") },
564
- ],
565
- server.slice(0, 200),
566
- { search: true },
567
- )}
568
-
569
- <h2>Browser side calls<span class="note">fetch requests made by islands</span></h2>
570
- ${table(
571
- "api-client",
572
- [
573
- { key: "url", label: "URL" },
574
- { key: "page", label: "Page", render: (row) => link(row.page) },
575
- { key: "status", label: "Status", render: (row) => `<span class="status ${row.status >= 400 || !row.status ? "bad" : ""}">${row.status || "—"}</span>` },
576
- { key: "ms", label: "Time", value: (row) => row.ms, render: (row) => `<span class="${tone(row.ms, [200, 800])}">${ms(row.ms)}</span>` },
577
- { key: "bytes", label: "Body", value: (row) => row.bytes, render: (row) => size(row.bytes) },
578
- ],
579
- client,
580
- { search: true },
581
- )}
582
- `;
583
- }
584
-
585
- function errorsTab() {
586
- return `
587
- <h2>Errors<span class="note">server log</span></h2>
588
- ${table(
589
- "errors",
590
- [
591
- { key: "message", label: "Message" },
592
- { key: "level", label: "Level", render: (row) => `<span class="tag">${escapeHtml(row.level)}</span>` },
593
- { key: "at", label: "Time", value: (row) => row.at, render: (row) => new Date(row.at).toLocaleTimeString() },
594
- ],
595
- data.errors ?? [],
596
- { search: true },
597
- )}
598
-
599
- <h2>Recent requests</h2>
600
- ${table(
601
- "requests",
602
- [
603
- { key: "url", label: "URL", render: (row) => link(row.url) },
604
- { key: "status", label: "Status", render: (row) => `<span class="status ${row.status >= 500 ? "bad" : row.status >= 400 ? "warn" : ""}">${row.status}</span>` },
605
- { key: "ms", label: "Time", value: (row) => row.ms, render: (row) => `<span class="${tone(row.ms, [150, 500])}">${ms(row.ms)}</span>` },
606
- { key: "cache", label: "Cache", render: (row) => (row.cache ? `<span class="tag">${escapeHtml(row.cache)}</span>` : "—") },
607
- { key: "at", label: "Time", value: (row) => row.at, render: (row) => new Date(row.at).toLocaleTimeString() },
608
- ],
609
- data.requests ?? [],
610
- { search: true },
611
- )}
612
- `;
613
- }
614
-
615
- const RENDERERS = {
616
- overview,
617
- pages: pagesTab,
618
- chunks: chunksTab,
619
- assets: assetsTab,
620
- ssr: ssrTab,
621
- api: apiTab,
622
- errors: errorsTab,
623
- };
624
-
625
- /* ------------------------------------------------------------------ çizim */
626
-
627
- function render() {
628
- const nav = document.querySelector("[data-part='tabs']");
629
- nav.innerHTML = TABS.map(
630
- (tab) =>
631
- `<button data-tab="${tab.id}" aria-selected="${tab.id === activeTab}">${tab.label}</button>`,
632
- ).join("");
633
-
634
- const main = document.querySelector("[data-part='main']");
635
- main.innerHTML = data
636
- ? RENDERERS[activeTab]()
637
- : `<div class="empty">Loading data…</div>`;
638
-
639
- const status = document.querySelector("[data-part='status']");
640
- if (data) {
641
- const time = new Date(data.generatedAt).toLocaleTimeString();
642
- status.textContent = `${data.pages.length} pages · ${time}`;
643
- }
644
-
645
- document.querySelector("[data-action='auto']").setAttribute("aria-pressed", String(auto));
646
- }
647
-
648
- async function load() {
649
- try {
650
- const response = await fetch(`${BASE}/report/data`, { cache: "no-store" });
651
- data = await response.json();
652
- } catch {
653
- document.querySelector("[data-part='status']").textContent = "server unreachable";
654
- return;
655
- }
656
- render();
657
- }
658
-
659
- /* ------------------------------------------------------------ etkileşim */
660
-
661
- document.addEventListener("click", (event) => {
662
- const target = event.target.closest("[data-tab], [data-sort], [data-chunk], [data-action]");
663
- if (!target) return;
664
-
665
- if (target.dataset.action === "refresh") {
666
- load();
667
- return;
668
- }
669
-
670
- if (target.dataset.action === "auto") {
671
- auto = !auto;
672
- clearInterval(timer);
673
- if (auto) timer = setInterval(load, 5000);
674
- render();
675
- return;
676
- }
677
-
678
- if (target.dataset.action === "reset") {
679
- fetch(`${BASE}/report/clear`, { method: "POST" }).finally(load);
680
- return;
681
- }
682
-
683
- if (target.dataset.tab) {
684
- activeTab = target.dataset.tab;
685
- location.hash = activeTab;
686
- render();
687
- return;
688
- }
689
-
690
- if (target.dataset.sort) {
691
- const id = target.closest("table").dataset.table;
692
- const current = sorting.get(id);
693
- sorting.set(id, {
694
- key: target.dataset.sort,
695
- dir: current?.key === target.dataset.sort && current.dir === "desc" ? "asc" : "desc",
696
- });
697
- render();
698
- return;
699
- }
700
-
701
- if (target.dataset.chunk) {
702
- selectedChunk = target.dataset.chunk;
703
- render();
704
- }
705
- });
706
-
707
- document.addEventListener("input", (event) => {
708
- const field = event.target.closest("[data-filter]");
709
- if (!field) return;
710
-
711
- filters.set(field.dataset.filter, field.value);
712
- render();
713
-
714
- // Yeniden çizim odağı düşürür; yazmaya devam edilebilsin.
715
- const next = document.querySelector(`[data-filter="${field.dataset.filter}"]`);
716
- next?.focus();
717
- next?.setSelectionRange(field.value.length, field.value.length);
718
- });
719
-
720
- if (TABS.some((tab) => tab.id === location.hash.slice(1))) {
721
- activeTab = location.hash.slice(1);
722
- }
723
-
724
- render();
725
- load();
1
+ /**
2
+ * "Inspect in detail" sayfasının arayüzü.
3
+ *
4
+ * Overlay ile aynı mantık: build'e girmez, dev sunucusu bu dosyayı ham olarak
5
+ * servis eder. Tüm veri tek bir uçtan gelir (`/__jskelet/dev/report/data`),
6
+ * sayfa yalnızca onu görselleştirir.
7
+ */
8
+
9
+ const BASE = "/__jskelet/dev";
10
+
11
+ /** @type {any} */
12
+ let data = null;
13
+ let activeTab = "overview";
14
+ let auto = false;
15
+ /** @type {number | null} */
16
+ let timer = null;
17
+
18
+ /** Tablo sıralamaları: tabloId → { key, dir }. */
19
+ const sorting = new Map();
20
+
21
+ /** Arama kutuları: tabloId → metin. */
22
+ const filters = new Map();
23
+
24
+ /** Chunk analizinde seçili çıktı. */
25
+ let selectedChunk = null;
26
+
27
+ const TABS = [
28
+ { id: "overview", label: "Overview" },
29
+ { id: "pages", label: "Pages" },
30
+ { id: "chunks", label: "Chunk analysis" },
31
+ { id: "assets", label: "Assets" },
32
+ { id: "ssr", label: "SSR & CSR" },
33
+ { id: "api", label: "API requests" },
34
+ { id: "errors", label: "Errors" },
35
+ ];
36
+
37
+ /* ------------------------------------------------------------- yardımcılar */
38
+
39
+ /** @param {number | null | undefined} bytes */
40
+ function size(bytes) {
41
+ if (bytes == null) return "—";
42
+ if (bytes >= 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(2)} MB`;
43
+ if (bytes >= 1024) return `${Math.round(bytes / 1024)} KB`;
44
+ return `${bytes} B`;
45
+ }
46
+
47
+ /** @param {number | null | undefined} value */
48
+ function ms(value) {
49
+ return value == null ? "—" : `${Math.round(value)} ms`;
50
+ }
51
+
52
+ /**
53
+ * @param {number | null | undefined} value
54
+ * @param {[number, number]} bounds iyi/orta sınırı
55
+ */
56
+ function tone(value, [good, mid]) {
57
+ if (value == null) return "";
58
+ if (value <= good) return "good";
59
+ if (value <= mid) return "mid";
60
+ return "bad";
61
+ }
62
+
63
+ /** @param {string} value */
64
+ function escapeHtml(value) {
65
+ return String(value ?? "").replace(
66
+ /[&<>"]/g,
67
+ (char) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" })[char],
68
+ );
69
+ }
70
+
71
+ /** @param {string} url */
72
+ function link(url) {
73
+ const text = escapeHtml(url);
74
+ if (!url?.startsWith("/")) return text;
75
+ return `<a href="${text}" target="_blank" rel="noreferrer">${text}</a>`;
76
+ }
77
+
78
+ /**
79
+ * @param {{ label: string, value: string, sub?: string, tone?: string, ratio?: number | null }[]} items
80
+ */
81
+ function metrics(items) {
82
+ return `<div class="grid">${items
83
+ .map((item) => {
84
+ const width = Math.round(Math.min(1, Math.max(0.03, item.ratio ?? 0)) * 100);
85
+ return `<div class="metric">
86
+ <div class="label">${escapeHtml(item.label)}</div>
87
+ <div class="value ${item.tone ?? ""}">${escapeHtml(item.value)}</div>
88
+ ${item.sub ? `<div class="sub">${item.sub}</div>` : ""}
89
+ ${
90
+ item.ratio == null
91
+ ? ""
92
+ : `<div class="bar"><span class="${item.tone ?? ""}" style="width:${width}%"></span></div>`
93
+ }
94
+ </div>`;
95
+ })
96
+ .join("")}</div>`;
97
+ }
98
+
99
+ /**
100
+ * Sıralanabilir, aranabilir tablo.
101
+ *
102
+ * @param {string} id
103
+ * @param {{ key: string, label: string, value?: (row: any) => any, render?: (row: any) => string }[]} columns
104
+ * @param {any[]} rows
105
+ * @param {{ search?: boolean, rowAttrs?: (row: any) => string }} [options]
106
+ */
107
+ function table(id, columns, rows, options = {}) {
108
+ const sort = sorting.get(id) ?? { key: columns[0].key, dir: "desc" };
109
+ const query = (filters.get(id) ?? "").trim().toLowerCase();
110
+
111
+ const valueOf = (row, column) =>
112
+ column.value ? column.value(row) : row[column.key];
113
+
114
+ let visible = rows;
115
+ if (query) {
116
+ visible = rows.filter((row) =>
117
+ columns.some((column) =>
118
+ String(valueOf(row, column) ?? "")
119
+ .toLowerCase()
120
+ .includes(query),
121
+ ),
122
+ );
123
+ }
124
+
125
+ const column = columns.find((item) => item.key === sort.key) ?? columns[0];
126
+ visible = [...visible].sort((a, b) => {
127
+ const left = valueOf(a, column);
128
+ const right = valueOf(b, column);
129
+ const compare =
130
+ typeof left === "number" && typeof right === "number"
131
+ ? left - right
132
+ : String(left ?? "").localeCompare(String(right ?? ""));
133
+ return sort.dir === "asc" ? compare : -compare;
134
+ });
135
+
136
+ const search = options.search
137
+ ? `<div class="toolbar">
138
+ <input type="search" data-filter="${id}" value="${escapeHtml(filters.get(id) ?? "")}"
139
+ placeholder="Filter — ${visible.length}/${rows.length} rows" />
140
+ </div>`
141
+ : "";
142
+
143
+ if (!rows.length) {
144
+ return `${search}<div class="empty">No records.</div>`;
145
+ }
146
+
147
+ return `${search}
148
+ <div class="table-wrap">
149
+ <table data-table="${id}">
150
+ <thead><tr>${columns
151
+ .map(
152
+ (item) =>
153
+ `<th data-sort="${item.key}" ${
154
+ item.key === sort.key ? `data-dir="${sort.dir}"` : ""
155
+ }>${escapeHtml(item.label)}</th>`,
156
+ )
157
+ .join("")}</tr></thead>
158
+ <tbody>${visible
159
+ .map(
160
+ (row) =>
161
+ `<tr ${options.rowAttrs?.(row) ?? ""}>${columns
162
+ .map(
163
+ (item) =>
164
+ `<td class="${item.key === columns[0].key ? "path" : ""}">${
165
+ item.render ? item.render(row) : escapeHtml(valueOf(row, item) ?? "—")
166
+ }</td>`,
167
+ )
168
+ .join("")}</tr>`,
169
+ )
170
+ .join("")}</tbody>
171
+ </table>
172
+ </div>`;
173
+ }
174
+
175
+ /* ------------------------------------------------------------- sekmeler */
176
+
177
+ function overview() {
178
+ const pages = data.pages ?? [];
179
+ const measured = pages.filter((page) => page.metrics?.lcp != null);
180
+ const lcps = measured.map((page) => page.metrics.lcp);
181
+ const worst = [...measured].sort((a, b) => b.metrics.lcp - a.metrics.lcp).slice(0, 8);
182
+
183
+ const average = lcps.length ? lcps.reduce((sum, value) => sum + value, 0) / lcps.length : null;
184
+ const js = (data.build?.outputs ?? []).filter((item) => item.file.endsWith(".js"));
185
+ const jsBytes = js.reduce((sum, item) => sum + item.bytes, 0);
186
+ const css = (data.build?.assets ?? []).filter((item) => item.kind === "css");
187
+ const cssBytes = css.reduce((sum, item) => sum + (item.bytes ?? 0), 0);
188
+ const ssrBytes = pages.reduce((sum, page) => sum + (page.html?.bytes ?? 0), 0);
189
+ const apiErrors = (data.serverApi ?? []).filter((call) => call.error).length;
190
+ const islandTotal = pages.reduce((sum, page) => sum + (page.islands?.total ?? 0), 0);
191
+ const islandReady = pages.reduce((sum, page) => sum + (page.islands?.ready ?? 0), 0);
192
+
193
+ return `
194
+ <h2>Site wide<span class="note">${pages.length} pages known, ${measured.length} of them measured in the browser</span></h2>
195
+ ${metrics([
196
+ {
197
+ label: "Average LCP",
198
+ value: ms(average),
199
+ tone: tone(average, [2500, 4000]),
200
+ ratio: average == null ? null : average / 4000,
201
+ sub: `worst ${ms(worst[0]?.metrics?.lcp)}`,
202
+ },
203
+ {
204
+ label: "Measured pages",
205
+ value: `${measured.length}/${pages.length}`,
206
+ ratio: pages.length ? measured.length / pages.length : null,
207
+ tone: "good",
208
+ sub: "browse to measure",
209
+ },
210
+ { label: "JS output", value: size(jsBytes), sub: `${js.length} files` },
211
+ { label: "CSS output", value: size(cssBytes), sub: `${css.length} files` },
212
+ { label: "SSR HTML", value: size(ssrBytes), sub: "total of known pages" },
213
+ {
214
+ label: "Island (CSR)",
215
+ value: `${islandReady}/${islandTotal}`,
216
+ ratio: islandTotal ? islandReady / islandTotal : null,
217
+ tone: "good",
218
+ sub: "mounted / marked",
219
+ },
220
+ {
221
+ label: "HTML cache",
222
+ value: String(data.cache?.size ?? 0),
223
+ sub: `${size((data.cache?.entries ?? []).reduce((sum, item) => sum + item.bytes, 0))} in memory`,
224
+ },
225
+ {
226
+ label: "Server API calls",
227
+ value: String((data.serverApi ?? []).length),
228
+ tone: apiErrors ? "bad" : "good",
229
+ sub: apiErrors ? `${apiErrors} errors` : "no errors",
230
+ },
231
+ // Upstream kotasının nasıl harcandığı: okumaların kaçı önbellekten
232
+ // döndü. Oran düşükse `withDataCache` TTL'i kısa demektir.
233
+ {
234
+ label: "Data cache",
235
+ value: data.cache?.dataStats?.reads
236
+ ? `${Math.round(data.cache.dataStats.hitRatio * 100)}%`
237
+ : "—",
238
+ ratio: data.cache?.dataStats?.hitRatio ?? null,
239
+ tone: "good",
240
+ sub: data.cache?.dataStats?.reads
241
+ ? `${data.cache.dataStats.produced} upstream / ${data.cache.dataStats.reads} reads`
242
+ : "no reads yet",
243
+ },
244
+ {
245
+ label: "Prewarm",
246
+ value: data.prewarm?.total
247
+ ? `${data.prewarm.ok}/${data.prewarm.total}`
248
+ : "—",
249
+ ratio: data.prewarm?.total ? data.prewarm.done / data.prewarm.total : null,
250
+ tone: data.prewarm?.failed ? "mid" : "good",
251
+ },
252
+ {
253
+ label: "Process",
254
+ value: size(data.process?.memory?.rss),
255
+ sub: `${data.process?.node} · ${Math.round(data.process?.uptime ?? 0)} s`,
256
+ },
257
+ ])}
258
+
259
+ <h2>Slowest pages<span class="note">by LCP</span></h2>
260
+ ${
261
+ worst.length
262
+ ? table(
263
+ "worst",
264
+ [
265
+ { key: "url", label: "Page", render: (row) => link(row.url) },
266
+ { key: "lcp", label: "LCP", value: (row) => row.metrics.lcp, render: (row) => `<span class="${tone(row.metrics.lcp, [2500, 4000])}">${ms(row.metrics.lcp)}</span>` },
267
+ { key: "cls", label: "CLS", value: (row) => row.metrics.cls ?? 0, render: (row) => (row.metrics.cls ?? 0).toFixed(3) },
268
+ { key: "inp", label: "INP", value: (row) => row.metrics.inp ?? 0, render: (row) => ms(row.metrics.inp) },
269
+ { key: "transfer", label: "Transfer", value: (row) => row.resources?.bytes ?? 0, render: (row) => size(row.resources?.bytes) },
270
+ { key: "html", label: "SSR HTML", value: (row) => row.html?.bytes ?? 0, render: (row) => size(row.html?.bytes) },
271
+ ],
272
+ worst,
273
+ )
274
+ : `<div class="empty">No browser measurements yet. Pages land here as you browse the site.</div>`
275
+ }
276
+
277
+ <h2>Web Vitals distribution</h2>
278
+ <div class="card">
279
+ ${["lcp", "fcp", "ttfb", "inp"]
280
+ .map((key) => {
281
+ const values = measured
282
+ .map((page) => page.metrics[key])
283
+ .filter((value) => value != null)
284
+ .sort((a, b) => a - b);
285
+ if (!values.length) return "";
286
+ const p = (ratio) => values[Math.min(values.length - 1, Math.floor(values.length * ratio))];
287
+ const bounds = { lcp: [2500, 4000], fcp: [1800, 3000], ttfb: [800, 1800], inp: [200, 500] }[key];
288
+ return `<div class="barrow">
289
+ <span class="name">${key.toUpperCase()} — median ${ms(p(0.5))}, p75 ${ms(p(0.75))}, p95 ${ms(p(0.95))}</span>
290
+ <span class="size ${tone(p(0.75), bounds)}">${ms(p(0.75))}</span>
291
+ <span class="track"><span class="${tone(p(0.75), bounds)}" style="width:${Math.min(100, (p(0.75) / (bounds[1] * 1.5)) * 100)}%"></span></span>
292
+ </div>`;
293
+ })
294
+ .join("")}
295
+ </div>
296
+ `;
297
+ }
298
+
299
+ function pagesTab() {
300
+ const rows = data.pages ?? [];
301
+
302
+ return `
303
+ <h2>Pages<span class="note">browser measurements build up as you browse; SSR columns come from the prewarm round</span></h2>
304
+ ${table(
305
+ "pages",
306
+ [
307
+ { key: "url", label: "Page", render: (row) => link(row.url) },
308
+ { key: "visits", label: "Visits", value: (row) => row.visits },
309
+ { key: "lcp", label: "LCP", value: (row) => row.metrics?.lcp ?? -1, render: (row) => `<span class="${tone(row.metrics?.lcp, [2500, 4000])}">${ms(row.metrics?.lcp)}</span>` },
310
+ { key: "cls", label: "CLS", value: (row) => row.metrics?.cls ?? -1, render: (row) => (row.metrics?.cls == null ? "—" : `<span class="${tone(row.metrics.cls * 1000, [100, 250])}">${row.metrics.cls.toFixed(3)}</span>`) },
311
+ { key: "inp", label: "INP", value: (row) => row.metrics?.inp ?? -1, render: (row) => `<span class="${tone(row.metrics?.inp, [200, 500])}">${ms(row.metrics?.inp)}</span>` },
312
+ { key: "fcp", label: "FCP", value: (row) => row.metrics?.fcp ?? -1, render: (row) => ms(row.metrics?.fcp) },
313
+ { key: "ttfb", label: "TTFB", value: (row) => row.metrics?.ttfb ?? -1, render: (row) => `<span class="${tone(row.metrics?.ttfb, [800, 1800])}">${ms(row.metrics?.ttfb)}</span>` },
314
+ { key: "blocking", label: "Blocking", value: (row) => row.metrics?.blocking ?? -1, render: (row) => ms(row.metrics?.blocking) },
315
+ { key: "requests", label: "Requests", value: (row) => row.resources?.count ?? 0 },
316
+ { key: "transfer", label: "Transfer", value: (row) => row.resources?.bytes ?? 0, render: (row) => size(row.resources?.bytes) },
317
+ { key: "islands", label: "Island", value: (row) => row.islands?.total ?? 0, render: (row) => (row.islands?.total ? `${row.islands.ready}/${row.islands.total}` : "—") },
318
+ { key: "api", label: "API", value: (row) => row.api?.length ?? 0 },
319
+ { key: "html", label: "SSR HTML", value: (row) => row.html?.bytes ?? 0, render: (row) => size(row.html?.bytes) },
320
+ { key: "render", label: "Render", value: (row) => row.html?.ms ?? -1, render: (row) => ms(row.html?.ms) },
321
+ { key: "cache", label: "Cache", render: (row) => (row.html?.cache ? `<span class="tag">${escapeHtml(row.html.cache)}</span>` : "—") },
322
+ ],
323
+ rows,
324
+ { search: true },
325
+ )}
326
+ `;
327
+ }
328
+
329
+ function chunksTab() {
330
+ const build = data.build ?? {};
331
+ if (!build.available) {
332
+ return `<div class="empty">Chunk analysis needs <code>build/generated/metafile.json</code>. It appears once <code>npm run dev</code> or <code>npm run build</code> has completed a round.</div>`;
333
+ }
334
+
335
+ const outputs = build.outputs ?? [];
336
+ const selected =
337
+ outputs.find((item) => item.file === selectedChunk) ?? outputs[0];
338
+ const heaviest = outputs[0]?.bytes ?? 1;
339
+ const totalBytes = outputs.reduce((sum, item) => sum + item.bytes, 0);
340
+ const groupTotal = (build.groups ?? []).reduce((sum, item) => sum + item.bytes, 0) || 1;
341
+
342
+ return `
343
+ <h2>Outputs<span class="note">${outputs.length} files · ${size(totalBytes)} raw</span></h2>
344
+ <div class="split">
345
+ <div>
346
+ ${table(
347
+ "chunks",
348
+ [
349
+ {
350
+ key: "file",
351
+ label: "File",
352
+ render: (row) =>
353
+ `${link(row.file)} <span class="tag ${row.isChunk ? "chunk" : "entry"}">${row.isChunk ? "chunk" : "entry"}</span>`,
354
+ },
355
+ { key: "bytes", label: "Raw", value: (row) => row.bytes, render: (row) => size(row.bytes) },
356
+ { key: "gzip", label: "Gzip", value: (row) => row.gzip ?? 0, render: (row) => size(row.gzip) },
357
+ { key: "brotli", label: "Brotli", value: (row) => row.brotli ?? 0, render: (row) => size(row.brotli) },
358
+ { key: "inputCount", label: "Modules", value: (row) => row.inputCount },
359
+ { key: "imports", label: "Imports", value: (row) => row.imports.length },
360
+ {
361
+ key: "share",
362
+ label: "Share",
363
+ value: (row) => row.bytes / heaviest,
364
+ render: (row) => `${Math.round((row.bytes / totalBytes) * 100)}%`,
365
+ },
366
+ ],
367
+ outputs,
368
+ {
369
+ search: true,
370
+ rowAttrs: (row) =>
371
+ `class="clickable ${row.file === selected?.file ? "selected" : ""}" data-chunk="${escapeHtml(row.file)}"`,
372
+ },
373
+ )}
374
+ </div>
375
+ <div>
376
+ <div class="card">
377
+ <h2 style="margin-top:0">Selected output</h2>
378
+ ${
379
+ selected
380
+ ? `<div class="hint" style="margin-bottom:10px">
381
+ <code>${escapeHtml(selected.file)}</code><br />
382
+ ${size(selected.bytes)} raw · ${size(selected.gzip)} gzip · ${selected.inputCount} modules
383
+ ${selected.entry ? `<br />entry: <code>${escapeHtml(selected.entry)}</code>` : ""}
384
+ </div>
385
+ <div class="bars">
386
+ ${selected.inputs
387
+ .map(
388
+ (input) => `<div class="barrow">
389
+ <span class="name" title="${escapeHtml(input.source)}">${escapeHtml(input.source)}</span>
390
+ <span class="size">${size(input.bytes)}</span>
391
+ <span class="track"><span style="width:${Math.round((input.bytes / selected.inputs[0].bytes) * 100)}%"></span></span>
392
+ </div>`,
393
+ )
394
+ .join("")}
395
+ </div>
396
+ ${
397
+ selected.imports.length
398
+ ? `<h2>Chunks it imports</h2>
399
+ <div class="hint">${selected.imports
400
+ .map((item) => `<div><code>${escapeHtml(item.path)}</code> <span class="tag">${escapeHtml(item.kind)}</span></div>`)
401
+ .join("")}</div>`
402
+ : ""
403
+ }`
404
+ : `<div class="hint">Pick an output on the left.</div>`
405
+ }
406
+ </div>
407
+ </div>
408
+ </div>
409
+
410
+ <h2>Source groups<span class="note">package / folder share across all outputs</span></h2>
411
+ <div class="card bars">
412
+ ${(build.groups ?? [])
413
+ .slice(0, 25)
414
+ .map(
415
+ (group) => `<div class="barrow">
416
+ <span class="name">${escapeHtml(group.name)}</span>
417
+ <span class="size">${size(group.bytes)}</span>
418
+ <span class="track"><span style="width:${Math.round((group.bytes / groupTotal) * 100)}%"></span></span>
419
+ </div>`,
420
+ )
421
+ .join("")}
422
+ </div>
423
+ `;
424
+ }
425
+
426
+ function assetsTab() {
427
+ const assets = data.build?.assets ?? [];
428
+ const total = assets.reduce((sum, item) => sum + (item.bytes ?? 0), 0);
429
+
430
+ return `
431
+ <h2>Manifest assets<span class="note">${assets.length} entries · ${size(total)}</span></h2>
432
+ ${table(
433
+ "assets",
434
+ [
435
+ { key: "name", label: "Name" },
436
+ { key: "url", label: "Path", render: (row) => link(row.url) },
437
+ { key: "kind", label: "Kind", render: (row) => `<span class="tag">${escapeHtml(row.kind)}</span>` },
438
+ { key: "bytes", label: "Raw", value: (row) => row.bytes ?? 0, render: (row) => size(row.bytes) },
439
+ { key: "gzip", label: "Gzip", value: (row) => row.gzip ?? 0, render: (row) => size(row.gzip) },
440
+ { key: "brotli", label: "Brotli", value: (row) => row.brotli ?? 0, render: (row) => size(row.brotli) },
441
+ {
442
+ key: "ratio",
443
+ label: "Savings",
444
+ value: (row) => (row.bytes && row.gzip ? 1 - row.gzip / row.bytes : 0),
445
+ render: (row) =>
446
+ row.bytes && row.gzip
447
+ ? `${Math.round((1 - row.gzip / row.bytes) * 100)}%`
448
+ : "—",
449
+ },
450
+ ],
451
+ assets,
452
+ { search: true },
453
+ )}
454
+ `;
455
+ }
456
+
457
+ function ssrTab() {
458
+ const pages = data.pages ?? [];
459
+ const cache = data.cache?.entries ?? [];
460
+
461
+ /** @type {Map<string, { name: string, pages: number }>} */
462
+ const islands = new Map();
463
+ for (const page of pages) {
464
+ for (const name of page.islands?.names ?? []) {
465
+ const entry = islands.get(name) ?? { name, pages: 0 };
466
+ entry.pages += 1;
467
+ islands.set(name, entry);
468
+ }
469
+ }
470
+
471
+ const csr = pages.filter((page) => (page.islands?.total ?? 0) > 0);
472
+ const partial = csr.filter((page) => page.islands.ready < page.islands.total);
473
+
474
+ return `
475
+ <h2>Server render & client hydration</h2>
476
+ ${metrics([
477
+ { label: "SSR pages", value: String(pages.filter((page) => page.html?.bytes).length), sub: "HTML size known" },
478
+ { label: "Pages with islands", value: `${csr.length}/${pages.length}`, ratio: pages.length ? csr.length / pages.length : null, tone: "good" },
479
+ { label: "Partial hydration", value: String(partial.length), tone: partial.length ? "mid" : "good", sub: "some islands did not mount" },
480
+ { label: "Distinct islands", value: String(islands.size) },
481
+ { label: "Cache entries", value: String(data.cache?.size ?? 0) },
482
+ { label: "Cache size", value: size(cache.reduce((sum, item) => sum + item.bytes, 0)) },
483
+ ])}
484
+
485
+ <h2>Island inventory<span class="note">islands seen on the pages you browsed</span></h2>
486
+ ${table(
487
+ "islands",
488
+ [
489
+ { key: "name", label: "Island" },
490
+ { key: "pages", label: "Page count", value: (row) => row.pages },
491
+ ],
492
+ [...islands.values()],
493
+ { search: true },
494
+ )}
495
+
496
+ <h2>HTML cache<span class="note">entries in memory, with time to stale</span></h2>
497
+ ${table(
498
+ "cache",
499
+ [
500
+ { key: "key", label: "Key" },
501
+ { key: "status", label: "Status", render: (row) => `<span class="status ${row.status >= 400 ? "bad" : ""}">${row.status}</span>` },
502
+ { key: "bytes", label: "HTML", value: (row) => row.bytes, render: (row) => size(row.bytes) },
503
+ { key: "expiresIn", label: "Freshness", value: (row) => row.expiresIn, render: (row) => (row.stale ? `<span class="mid">stale</span>` : `${row.expiresIn} s`) },
504
+ { key: "encodings", label: "Compression", render: (row) => (row.encodings.length ? row.encodings.map((item) => `<span class="tag">${escapeHtml(item)}</span>`).join(" ") : "—") },
505
+ ],
506
+ cache,
507
+ { search: true },
508
+ )}
509
+ `;
510
+ }
511
+
512
+ function apiTab() {
513
+ const server = data.serverApi ?? [];
514
+
515
+ /** @type {Map<string, { key: string, host: string, calls: number, ms: number, errors: number, bytes: number }>} */
516
+ const grouped = new Map();
517
+ for (const call of server) {
518
+ let route = call.url;
519
+ try {
520
+ route = new URL(call.url).pathname;
521
+ } catch {
522
+ // Göreli adres.
523
+ }
524
+ const key = `${call.host}${route}`;
525
+ const entry = grouped.get(key) ?? { key: route, host: call.host, calls: 0, ms: 0, errors: 0, bytes: 0 };
526
+ entry.calls += 1;
527
+ entry.ms += call.ms;
528
+ entry.errors += call.error ? 1 : 0;
529
+ entry.bytes += call.bytes;
530
+ grouped.set(key, entry);
531
+ }
532
+
533
+ const client = (data.pages ?? []).flatMap((page) =>
534
+ (page.api ?? []).map((call) => ({ ...call, page: page.url })),
535
+ );
536
+
537
+ return `
538
+ <h2>Server side calls<span class="note">outbound requests made during SSR, grouped by path</span></h2>
539
+ ${table(
540
+ "api-groups",
541
+ [
542
+ { key: "key", label: "Path" },
543
+ { key: "host", label: "Host" },
544
+ { key: "calls", label: "Calls", value: (row) => row.calls },
545
+ { key: "avg", label: "Avg. time", value: (row) => row.ms / row.calls, render: (row) => `<span class="${tone(row.ms / row.calls, [200, 800])}">${ms(row.ms / row.calls)}</span>` },
546
+ { key: "total", label: "Total time", value: (row) => row.ms, render: (row) => ms(row.ms) },
547
+ { key: "bytes", label: "Body", value: (row) => row.bytes, render: (row) => size(row.bytes) },
548
+ { key: "errors", label: "Errors", value: (row) => row.errors, render: (row) => (row.errors ? `<span class="bad">${row.errors}</span>` : "0") },
549
+ ],
550
+ [...grouped.values()],
551
+ { search: true },
552
+ )}
553
+
554
+ <h2>Recent calls</h2>
555
+ ${table(
556
+ "api-calls",
557
+ [
558
+ { key: "url", label: "URL" },
559
+ { key: "method", label: "Method" },
560
+ { key: "status", label: "Status", render: (row) => `<span class="status ${row.error ? "bad" : ""}">${row.status || "—"}</span>` },
561
+ { key: "ms", label: "Time", value: (row) => row.ms, render: (row) => `<span class="${tone(row.ms, [200, 800])}">${ms(row.ms)}</span>` },
562
+ { key: "bytes", label: "Body", value: (row) => row.bytes, render: (row) => size(row.bytes) },
563
+ { key: "error", label: "Error", render: (row) => (row.error ? `<span class="bad">${escapeHtml(row.error)}</span>` : "—") },
564
+ ],
565
+ server.slice(0, 200),
566
+ { search: true },
567
+ )}
568
+
569
+ <h2>Browser side calls<span class="note">fetch requests made by islands</span></h2>
570
+ ${table(
571
+ "api-client",
572
+ [
573
+ { key: "url", label: "URL" },
574
+ { key: "page", label: "Page", render: (row) => link(row.page) },
575
+ { key: "status", label: "Status", render: (row) => `<span class="status ${row.status >= 400 || !row.status ? "bad" : ""}">${row.status || "—"}</span>` },
576
+ { key: "ms", label: "Time", value: (row) => row.ms, render: (row) => `<span class="${tone(row.ms, [200, 800])}">${ms(row.ms)}</span>` },
577
+ { key: "bytes", label: "Body", value: (row) => row.bytes, render: (row) => size(row.bytes) },
578
+ ],
579
+ client,
580
+ { search: true },
581
+ )}
582
+ `;
583
+ }
584
+
585
+ function errorsTab() {
586
+ return `
587
+ <h2>Errors<span class="note">server log</span></h2>
588
+ ${table(
589
+ "errors",
590
+ [
591
+ { key: "message", label: "Message" },
592
+ { key: "level", label: "Level", render: (row) => `<span class="tag">${escapeHtml(row.level)}</span>` },
593
+ { key: "at", label: "Time", value: (row) => row.at, render: (row) => new Date(row.at).toLocaleTimeString() },
594
+ ],
595
+ data.errors ?? [],
596
+ { search: true },
597
+ )}
598
+
599
+ <h2>Recent requests</h2>
600
+ ${table(
601
+ "requests",
602
+ [
603
+ { key: "url", label: "URL", render: (row) => link(row.url) },
604
+ { key: "status", label: "Status", render: (row) => `<span class="status ${row.status >= 500 ? "bad" : row.status >= 400 ? "warn" : ""}">${row.status}</span>` },
605
+ { key: "ms", label: "Time", value: (row) => row.ms, render: (row) => `<span class="${tone(row.ms, [150, 500])}">${ms(row.ms)}</span>` },
606
+ { key: "cache", label: "Cache", render: (row) => (row.cache ? `<span class="tag">${escapeHtml(row.cache)}</span>` : "—") },
607
+ { key: "at", label: "Time", value: (row) => row.at, render: (row) => new Date(row.at).toLocaleTimeString() },
608
+ ],
609
+ data.requests ?? [],
610
+ { search: true },
611
+ )}
612
+ `;
613
+ }
614
+
615
+ const RENDERERS = {
616
+ overview,
617
+ pages: pagesTab,
618
+ chunks: chunksTab,
619
+ assets: assetsTab,
620
+ ssr: ssrTab,
621
+ api: apiTab,
622
+ errors: errorsTab,
623
+ };
624
+
625
+ /* ------------------------------------------------------------------ çizim */
626
+
627
+ function render() {
628
+ const nav = document.querySelector("[data-part='tabs']");
629
+ nav.innerHTML = TABS.map(
630
+ (tab) =>
631
+ `<button data-tab="${tab.id}" aria-selected="${tab.id === activeTab}">${tab.label}</button>`,
632
+ ).join("");
633
+
634
+ const main = document.querySelector("[data-part='main']");
635
+ main.innerHTML = data
636
+ ? RENDERERS[activeTab]()
637
+ : `<div class="empty">Loading data…</div>`;
638
+
639
+ const status = document.querySelector("[data-part='status']");
640
+ if (data) {
641
+ const time = new Date(data.generatedAt).toLocaleTimeString();
642
+ status.textContent = `${data.pages.length} pages · ${time}`;
643
+ }
644
+
645
+ document.querySelector("[data-action='auto']").setAttribute("aria-pressed", String(auto));
646
+ }
647
+
648
+ async function load() {
649
+ try {
650
+ const response = await fetch(`${BASE}/report/data`, { cache: "no-store" });
651
+ data = await response.json();
652
+ } catch {
653
+ document.querySelector("[data-part='status']").textContent = "server unreachable";
654
+ return;
655
+ }
656
+ render();
657
+ }
658
+
659
+ /* ------------------------------------------------------------ etkileşim */
660
+
661
+ document.addEventListener("click", (event) => {
662
+ const target = event.target.closest("[data-tab], [data-sort], [data-chunk], [data-action]");
663
+ if (!target) return;
664
+
665
+ if (target.dataset.action === "refresh") {
666
+ load();
667
+ return;
668
+ }
669
+
670
+ if (target.dataset.action === "auto") {
671
+ auto = !auto;
672
+ clearInterval(timer);
673
+ if (auto) timer = setInterval(load, 5000);
674
+ render();
675
+ return;
676
+ }
677
+
678
+ if (target.dataset.action === "reset") {
679
+ fetch(`${BASE}/report/clear`, { method: "POST" }).finally(load);
680
+ return;
681
+ }
682
+
683
+ if (target.dataset.tab) {
684
+ activeTab = target.dataset.tab;
685
+ location.hash = activeTab;
686
+ render();
687
+ return;
688
+ }
689
+
690
+ if (target.dataset.sort) {
691
+ const id = target.closest("table").dataset.table;
692
+ const current = sorting.get(id);
693
+ sorting.set(id, {
694
+ key: target.dataset.sort,
695
+ dir: current?.key === target.dataset.sort && current.dir === "desc" ? "asc" : "desc",
696
+ });
697
+ render();
698
+ return;
699
+ }
700
+
701
+ if (target.dataset.chunk) {
702
+ selectedChunk = target.dataset.chunk;
703
+ render();
704
+ }
705
+ });
706
+
707
+ document.addEventListener("input", (event) => {
708
+ const field = event.target.closest("[data-filter]");
709
+ if (!field) return;
710
+
711
+ filters.set(field.dataset.filter, field.value);
712
+ render();
713
+
714
+ // Yeniden çizim odağı düşürür; yazmaya devam edilebilsin.
715
+ const next = document.querySelector(`[data-filter="${field.dataset.filter}"]`);
716
+ next?.focus();
717
+ next?.setSelectionRange(field.value.length, field.value.length);
718
+ });
719
+
720
+ if (TABS.some((tab) => tab.id === location.hash.slice(1))) {
721
+ activeTab = location.hash.slice(1);
722
+ }
723
+
724
+ render();
725
+ load();