jskelet 0.2.5 → 0.3.0

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 (106) hide show
  1. package/AGENTS.md +132 -132
  2. package/CHANGELOG.md +403 -383
  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 +293 -287
  7. package/docs/03-routing.md +486 -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 +1231 -1209
  11. package/docs/07-yapilandirma.md +44 -21
  12. package/docs/08-build.md +366 -366
  13. package/docs/09-dev-araclari.md +335 -335
  14. package/docs/10-dagitim.md +329 -329
  15. package/docs/11-tasima.md +1 -0
  16. package/docs/12-panel-ve-oturum.md +384 -384
  17. package/docs/README.md +105 -105
  18. package/docs/en/01-getting-started.md +292 -292
  19. package/docs/en/02-architecture.md +311 -305
  20. package/docs/en/03-routing.md +503 -497
  21. package/docs/en/04-rendering.md +504 -504
  22. package/docs/en/05-islands.md +492 -492
  23. package/docs/en/06-caching.md +1197 -1198
  24. package/docs/en/07-configuration.md +1009 -986
  25. package/docs/en/08-build.md +383 -383
  26. package/docs/en/09-dev-tools.md +342 -342
  27. package/docs/en/10-deployment.md +332 -332
  28. package/docs/en/11-migration.md +360 -359
  29. package/docs/en/12-dashboards-and-sessions.md +392 -392
  30. package/docs/en/README.md +112 -112
  31. package/package.json +102 -102
  32. package/src/build/ensure-build.mjs +15 -15
  33. package/src/build/paths.mjs +143 -143
  34. package/src/build/resolve-peer.mjs +36 -36
  35. package/src/build/tasks/client.mjs +268 -268
  36. package/src/build/tasks/css.mjs +124 -124
  37. package/src/build/tasks/fonts.mjs +146 -146
  38. package/src/build/tasks/icons.mjs +224 -224
  39. package/src/build/tasks/images.mjs +244 -244
  40. package/src/build/tasks/precompress.mjs +78 -78
  41. package/src/client/{cache-panel → admin}/i18n.js +756 -670
  42. package/src/client/{cache-panel → admin}/login.html +74 -74
  43. package/src/client/{cache-panel → admin}/panel.css +804 -756
  44. package/src/client/admin/panel.html +486 -0
  45. package/src/client/{cache-panel → admin}/panel.js +1242 -915
  46. package/src/client/devtools/report.html +185 -185
  47. package/src/client/devtools/report.js +725 -725
  48. package/src/client/dom.js +95 -95
  49. package/src/client/form.js +192 -192
  50. package/src/client/index.js +35 -35
  51. package/src/client/registry.js +297 -297
  52. package/src/client/safe-image.js +91 -91
  53. package/src/client/store.js +36 -36
  54. package/src/client/swap.js +188 -188
  55. package/src/config/defaults.js +16 -7
  56. package/src/config/index.js +38 -22
  57. package/src/config/pattern.js +107 -107
  58. package/src/http/control-flow.js +71 -71
  59. package/src/http/cookies.js +257 -257
  60. package/src/http/request-cache.js +46 -46
  61. package/src/http/request-context.js +162 -162
  62. package/src/index.js +83 -83
  63. package/src/init.mjs +221 -221
  64. package/src/log.mjs +58 -0
  65. package/src/runtime/alias-hooks.mjs +119 -119
  66. package/src/runtime/register.mjs +4 -4
  67. package/src/server/admin/actions.js +229 -0
  68. package/src/server/admin/auth.js +125 -0
  69. package/src/server/admin/event-log.js +151 -0
  70. package/src/server/admin/gate.js +209 -0
  71. package/src/server/admin/inventory.js +188 -0
  72. package/src/server/admin/mount.js +56 -0
  73. package/src/server/admin/router.js +216 -0
  74. package/src/server/admin/snapshot.js +126 -0
  75. package/src/server/assets.js +147 -147
  76. package/src/server/cache-deps.js +42 -42
  77. package/src/server/cloudflare.js +607 -607
  78. package/src/server/create-app.js +295 -291
  79. package/src/server/data-cache.js +462 -462
  80. package/src/server/dev/report.js +369 -369
  81. package/src/server/dev/socket.js +170 -170
  82. package/src/server/dev/version-check.mjs +139 -139
  83. package/src/server/html-cache.js +817 -817
  84. package/src/server/metadata.js +102 -102
  85. package/src/server/middleware/compression.js +205 -205
  86. package/src/server/middleware/csrf.js +134 -134
  87. package/src/server/middleware/dev-gate.js +62 -62
  88. package/src/server/middleware/headers.js +37 -37
  89. package/src/server/middleware/redirects.js +32 -32
  90. package/src/server/middleware/static-precompressed.js +100 -100
  91. package/src/server/middleware/trailing-slash.js +53 -0
  92. package/src/server/middleware/upstream-proxy.js +141 -141
  93. package/src/server/prewarm.js +601 -601
  94. package/src/server/redis.js +569 -569
  95. package/src/server/router.js +128 -128
  96. package/src/server/status-page.js +164 -164
  97. package/src/server/upstream-limiter.js +376 -376
  98. package/src/server/upstream-tracking.js +166 -166
  99. package/src/start.mjs +7 -7
  100. package/src/templates/layout.ejs +44 -44
  101. package/src/version.mjs +31 -31
  102. package/src/views/components/loader.js +85 -85
  103. package/src/views/helpers/html.js +102 -102
  104. package/src/views/helpers/tags.js +245 -245
  105. package/src/client/cache-panel/panel.html +0 -308
  106. package/src/server/cache-panel.js +0 -759
@@ -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();