har-mcp 0.1.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.
@@ -0,0 +1,90 @@
1
+ /**
2
+ * Content Formatter for Read HAR Tool
3
+ */
4
+
5
+ import type { HarEntry } from "../../../types/har.ts"
6
+ import { formatBytes, formatContentForDisplay, getCodeFenceLanguage } from "../helpers.ts"
7
+ import type { ReadHarInput } from "../schema.ts"
8
+
9
+ /**
10
+ * Formats content mode output for a single entry
11
+ */
12
+ export function formatEntryContent(
13
+ index: number,
14
+ entry: HarEntry,
15
+ options: NonNullable<ReadHarInput["contentOptions"]>,
16
+ ): string {
17
+ const lines: string[] = []
18
+
19
+ lines.push(`# Entry #${index} Content`)
20
+ lines.push("")
21
+
22
+ // Request summary
23
+ lines.push("## Request")
24
+ lines.push(`- **${entry.request.method}** ${entry.request.url}`)
25
+ lines.push(`- **Status:** ${entry.response.status} ${entry.response.statusText}`)
26
+ lines.push("")
27
+
28
+ // Request body
29
+ if (options.includeRequestBody && entry.request.postData?.text) {
30
+ const mimeType = entry.request.postData.mimeType
31
+ const size = entry.request.bodySize
32
+ let content = entry.request.postData.text
33
+ let truncated = false
34
+
35
+ if (content.length > options.maxContentLength) {
36
+ content = content.slice(0, options.maxContentLength)
37
+ truncated = true
38
+ }
39
+
40
+ lines.push(`### Request Body (${mimeType}, ${formatBytes(size)})`)
41
+
42
+ // Determine code fence language
43
+ const lang = getCodeFenceLanguage(mimeType)
44
+ lines.push(`\`\`\`${lang}`)
45
+ lines.push(formatContentForDisplay(content, mimeType))
46
+ lines.push("```")
47
+
48
+ if (truncated) {
49
+ lines.push(`> Content truncated at ${options.maxContentLength.toLocaleString()} characters`)
50
+ }
51
+ lines.push("")
52
+ }
53
+
54
+ // Response body
55
+ if (options.includeResponseBody && entry.response.content.text) {
56
+ const mimeType = entry.response.content.mimeType
57
+ const size = entry.response.content.size
58
+ let content = entry.response.content.text
59
+ let truncated = false
60
+
61
+ // Handle base64 encoded content
62
+ if (entry.response.content.encoding === "base64") {
63
+ try {
64
+ content = Buffer.from(content, "base64").toString("utf-8")
65
+ } catch {
66
+ content = "[Base64 content - unable to decode as UTF-8]"
67
+ }
68
+ }
69
+
70
+ if (content.length > options.maxContentLength) {
71
+ content = content.slice(0, options.maxContentLength)
72
+ truncated = true
73
+ }
74
+
75
+ lines.push(`### Response Body (${mimeType}, ${formatBytes(size)})`)
76
+
77
+ // Determine code fence language
78
+ const lang = getCodeFenceLanguage(mimeType)
79
+ lines.push(`\`\`\`${lang}`)
80
+ lines.push(formatContentForDisplay(content, mimeType))
81
+ lines.push("```")
82
+
83
+ if (truncated) {
84
+ lines.push(`> Content truncated at ${options.maxContentLength.toLocaleString()} characters`)
85
+ }
86
+ lines.push("")
87
+ }
88
+
89
+ return lines.join("\n")
90
+ }
@@ -0,0 +1,164 @@
1
+ /**
2
+ * Cookies Analysis Formatter for Read HAR Tool
3
+ */
4
+
5
+ import type { HarFile } from "../../../types/har.ts"
6
+
7
+ /**
8
+ * Formats cookies analysis mode output - tracks cookie propagation
9
+ */
10
+ export function formatCookiesAnalysis(harFile: HarFile, filename: string): string {
11
+ const entries = harFile.log.entries
12
+ const lines: string[] = []
13
+
14
+ // Track all cookies
15
+ const cookieHistory = new Map<
16
+ string,
17
+ {
18
+ domain: string
19
+ setAt: { index: number; url: string; value: string }[]
20
+ sentAt: { index: number; url: string }[]
21
+ values: Set<string>
22
+ }
23
+ >()
24
+
25
+ for (let i = 0; i < entries.length; i++) {
26
+ const entry = entries[i]!
27
+ let domain: string
28
+ try {
29
+ domain = new URL(entry.request.url).hostname
30
+ } catch {
31
+ domain = "unknown"
32
+ }
33
+
34
+ // Track cookies sent in request
35
+ for (const cookie of entry.request.cookies) {
36
+ const existing = cookieHistory.get(cookie.name) || {
37
+ domain,
38
+ setAt: [],
39
+ sentAt: [],
40
+ values: new Set<string>(),
41
+ }
42
+ existing.sentAt.push({ index: i, url: entry.request.url })
43
+ existing.values.add(cookie.value)
44
+ cookieHistory.set(cookie.name, existing)
45
+ }
46
+
47
+ // Track cookies set in response
48
+ for (const cookie of entry.response.cookies) {
49
+ const existing = cookieHistory.get(cookie.name) || {
50
+ domain: cookie.domain || domain,
51
+ setAt: [],
52
+ sentAt: [],
53
+ values: new Set<string>(),
54
+ }
55
+ existing.setAt.push({ index: i, url: entry.request.url, value: cookie.value })
56
+ existing.values.add(cookie.value)
57
+ if (cookie.domain) existing.domain = cookie.domain
58
+ cookieHistory.set(cookie.name, existing)
59
+ }
60
+
61
+ // Also check Set-Cookie headers
62
+ for (const header of entry.response.headers) {
63
+ if (header.name.toLowerCase() === "set-cookie") {
64
+ const match = header.value.match(/^([^=]+)=([^;]*)/)
65
+ if (match) {
66
+ const [, name, value] = match
67
+ if (name && value !== undefined) {
68
+ const existing = cookieHistory.get(name) || {
69
+ domain,
70
+ setAt: [],
71
+ sentAt: [],
72
+ values: new Set<string>(),
73
+ }
74
+ existing.setAt.push({ index: i, url: entry.request.url, value })
75
+ existing.values.add(value)
76
+ cookieHistory.set(name, existing)
77
+ }
78
+ }
79
+ }
80
+ }
81
+ }
82
+
83
+ lines.push(`# HAR Cookie Analysis: ${filename}`)
84
+ lines.push("")
85
+
86
+ if (cookieHistory.size === 0) {
87
+ lines.push("No cookies found in this HAR file.")
88
+ return lines.join("\n")
89
+ }
90
+
91
+ lines.push(`**Total Unique Cookies:** ${cookieHistory.size}`)
92
+ lines.push("")
93
+
94
+ // Cookie Summary Table
95
+ lines.push("## Cookie Summary")
96
+ lines.push("| Cookie Name | Domain | Set Count | Sent Count | Value Changes |")
97
+ lines.push("|-------------|--------|-----------|------------|---------------|")
98
+
99
+ const sortedCookies = Array.from(cookieHistory.entries()).sort(
100
+ (a, b) => b[1].setAt.length + b[1].sentAt.length - (a[1].setAt.length + a[1].sentAt.length),
101
+ )
102
+
103
+ for (const [name, data] of sortedCookies) {
104
+ const valueChanges = data.values.size > 1 ? `${data.values.size} values` : "static"
105
+ lines.push(
106
+ `| ${name.slice(0, 20)} | ${data.domain.slice(0, 15)} | ${data.setAt.length} | ${data.sentAt.length} | ${valueChanges} |`,
107
+ )
108
+ }
109
+ lines.push("")
110
+
111
+ // Session cookies (likely auth-related)
112
+ const sessionCookies = Array.from(cookieHistory.entries()).filter(([name]) =>
113
+ /session|auth|token|jwt|sid/i.test(name),
114
+ )
115
+
116
+ if (sessionCookies.length > 0) {
117
+ lines.push("## Session/Auth Cookies")
118
+ for (const [name, data] of sessionCookies) {
119
+ lines.push(`### ${name}`)
120
+ lines.push(`- **Domain:** ${data.domain}`)
121
+ lines.push(`- **Value Changes:** ${data.values.size}`)
122
+
123
+ if (data.setAt.length > 0) {
124
+ lines.push(`- **First Set:** Entry #${data.setAt[0]?.index}`)
125
+ }
126
+ if (data.sentAt.length > 0) {
127
+ lines.push(`- **First Sent:** Entry #${data.sentAt[0]?.index}`)
128
+ lines.push(`- **Last Sent:** Entry #${data.sentAt[data.sentAt.length - 1]?.index}`)
129
+ }
130
+ lines.push("")
131
+ }
132
+ }
133
+
134
+ // Cookie flow timeline
135
+ lines.push("## Cookie Flow (first 10 events)")
136
+
137
+ type CookieEvent = { index: number; type: "set" | "sent"; cookie: string; url: string }
138
+ const events: CookieEvent[] = []
139
+
140
+ for (const [name, data] of cookieHistory) {
141
+ for (const set of data.setAt) {
142
+ events.push({ index: set.index, type: "set", cookie: name, url: set.url })
143
+ }
144
+ for (const sent of data.sentAt.slice(0, 3)) {
145
+ // Limit sent events
146
+ events.push({ index: sent.index, type: "sent", cookie: name, url: sent.url })
147
+ }
148
+ }
149
+
150
+ events.sort((a, b) => a.index - b.index)
151
+
152
+ for (const event of events.slice(0, 10)) {
153
+ const icon = event.type === "set" ? "SET" : "SENT"
154
+ let url: string
155
+ try {
156
+ url = new URL(event.url).pathname.slice(0, 30)
157
+ } catch {
158
+ url = event.url.slice(0, 30)
159
+ }
160
+ lines.push(`- #${event.index} [${icon}] **${event.cookie}** ${event.type === "set" ? "set by" : "sent to"} ${url}`)
161
+ }
162
+
163
+ return lines.join("\n")
164
+ }
@@ -0,0 +1,148 @@
1
+ /**
2
+ * Detail Formatter for Read HAR Tool
3
+ */
4
+
5
+ import type { HarEntry } from "../../../types/har.ts"
6
+ import { formatBytes, formatDuration, getTotalTiming, isError, truncateUrl } from "../helpers.ts"
7
+ import type { ReadHarInput } from "../schema.ts"
8
+
9
+ /**
10
+ * Formats detail mode output for a single entry
11
+ */
12
+ export function formatEntryDetail(
13
+ index: number,
14
+ entry: HarEntry,
15
+ include: NonNullable<ReadHarInput["include"]>,
16
+ ): string {
17
+ const lines: string[] = []
18
+
19
+ lines.push(`# Entry #${index} Details`)
20
+ lines.push("")
21
+
22
+ // Request section
23
+ lines.push("## Request")
24
+ lines.push(`- **Method:** ${entry.request.method}`)
25
+ lines.push(`- **URL:** ${entry.request.url}`)
26
+ lines.push(`- **HTTP Version:** ${entry.request.httpVersion}`)
27
+ lines.push("")
28
+
29
+ // Query Parameters
30
+ if (include.queryParams && entry.request.queryString.length > 0) {
31
+ lines.push("### Query Parameters")
32
+ lines.push("| Name | Value |")
33
+ lines.push("|------|-------|")
34
+ for (const param of entry.request.queryString) {
35
+ lines.push(`| ${param.name} | ${truncateUrl(param.value, 50)} |`)
36
+ }
37
+ lines.push("")
38
+ }
39
+
40
+ // Request Headers
41
+ if (include.headers && entry.request.headers.length > 0) {
42
+ lines.push("### Headers")
43
+ lines.push("| Name | Value |")
44
+ lines.push("|------|-------|")
45
+ for (const header of entry.request.headers) {
46
+ // Redact sensitive headers
47
+ let value = header.value
48
+ if (/^(authorization|cookie|x-api-key|x-auth)/i.test(header.name)) {
49
+ value = "[REDACTED]"
50
+ }
51
+ lines.push(`| ${header.name} | ${truncateUrl(value, 50)} |`)
52
+ }
53
+ lines.push("")
54
+ }
55
+
56
+ // Request Cookies
57
+ if (include.cookies && entry.request.cookies.length > 0) {
58
+ lines.push("### Cookies")
59
+ lines.push("| Name | Value |")
60
+ lines.push("|------|-------|")
61
+ for (const cookie of entry.request.cookies) {
62
+ lines.push(`| ${cookie.name} | ${truncateUrl(cookie.value, 50)} |`)
63
+ }
64
+ lines.push("")
65
+ }
66
+
67
+ // Request Body info
68
+ if (entry.request.postData) {
69
+ lines.push("### Body")
70
+ lines.push(`- **Type:** ${entry.request.postData.mimeType}`)
71
+ lines.push(`- **Size:** ${formatBytes(entry.request.bodySize)}`)
72
+ lines.push('- _(Content not included - use mode: "content" to view)_')
73
+ lines.push("")
74
+ }
75
+
76
+ // Response section
77
+ lines.push("## Response")
78
+ lines.push(`- **Status:** ${entry.response.status} ${entry.response.statusText}`)
79
+ lines.push(`- **HTTP Version:** ${entry.response.httpVersion}`)
80
+ lines.push("")
81
+
82
+ // Response Headers
83
+ if (include.headers && entry.response.headers.length > 0) {
84
+ lines.push("### Headers")
85
+ lines.push("| Name | Value |")
86
+ lines.push("|------|-------|")
87
+ for (const header of entry.response.headers) {
88
+ // Redact sensitive headers
89
+ let value = header.value
90
+ if (/^(set-cookie)/i.test(header.name)) {
91
+ value = "[REDACTED]"
92
+ }
93
+ lines.push(`| ${header.name} | ${truncateUrl(value, 50)} |`)
94
+ }
95
+ lines.push("")
96
+ }
97
+
98
+ // Response Cookies
99
+ if (include.cookies && entry.response.cookies.length > 0) {
100
+ lines.push("### Cookies")
101
+ lines.push("| Name | Value |")
102
+ lines.push("|------|-------|")
103
+ for (const cookie of entry.response.cookies) {
104
+ lines.push(`| ${cookie.name} | ${truncateUrl(cookie.value, 50)} |`)
105
+ }
106
+ lines.push("")
107
+ }
108
+
109
+ // Response Body info
110
+ lines.push("### Body")
111
+ lines.push(`- **Type:** ${entry.response.content.mimeType}`)
112
+ lines.push(`- **Size:** ${formatBytes(entry.response.content.size)}`)
113
+ lines.push('- _(Content not included - use mode: "content" to view)_')
114
+ lines.push("")
115
+
116
+ // Timing section
117
+ if (include.timing) {
118
+ const timings = entry.timings
119
+ lines.push("## Timing Breakdown")
120
+ lines.push("| Phase | Duration |")
121
+ lines.push("|-------|----------|")
122
+
123
+ const formatTiming = (val?: number): string => {
124
+ if (val === undefined || val < 0) return "-"
125
+ return formatDuration(val)
126
+ }
127
+
128
+ lines.push(`| Blocked | ${formatTiming(timings.blocked)} |`)
129
+ lines.push(`| DNS | ${formatTiming(timings.dns)} |`)
130
+ lines.push(`| Connect | ${formatTiming(timings.connect)} |`)
131
+ lines.push(`| SSL | ${formatTiming(timings.ssl)} |`)
132
+ lines.push(`| Send | ${formatTiming(timings.send)} |`)
133
+ lines.push(`| Wait (TTFB) | ${formatTiming(timings.wait)} |`)
134
+ lines.push(`| Receive | ${formatTiming(timings.receive)} |`)
135
+
136
+ const total = getTotalTiming(timings)
137
+ lines.push(`| **Total** | **${formatDuration(total)}** |`)
138
+ lines.push("")
139
+
140
+ // Add note about high wait times for errors
141
+ if (isError(entry) && timings.wait > 1000) {
142
+ lines.push(`> Note: Server took ${formatDuration(timings.wait)} before returning error (high wait time)`)
143
+ lines.push("")
144
+ }
145
+ }
146
+
147
+ return lines.join("\n")
148
+ }
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Formatters Index - Re-export all formatters
3
+ */
4
+
5
+ export { formatSummary } from "./summary.ts"
6
+ export { formatList } from "./list.ts"
7
+ export { formatEntryDetail } from "./detail.ts"
8
+ export { formatEntryContent } from "./content.ts"
9
+ export { formatTimeline } from "./timeline.ts"
10
+ export { formatStats } from "./stats.ts"
11
+ export { formatSizeAnalysis } from "./size.ts"
12
+ export { formatCookiesAnalysis } from "./cookies.ts"
@@ -0,0 +1,89 @@
1
+ /**
2
+ * List Formatter for Read HAR Tool
3
+ */
4
+
5
+ import type { IndexedEntry } from "../filters.ts"
6
+ import { formatBytes, formatDuration, getResponseSize, getUrlPath, isError, truncateUrl } from "../helpers.ts"
7
+ import type { ReadHarInput } from "../schema.ts"
8
+
9
+ /**
10
+ * Formats list mode output
11
+ */
12
+ export function formatList(
13
+ indexedEntries: IndexedEntry[],
14
+ filename: string,
15
+ page: number,
16
+ pageSize: number,
17
+ totalCount: number,
18
+ filter?: ReadHarInput["filter"],
19
+ ): string {
20
+ const lines: string[] = []
21
+ const totalPages = Math.ceil(totalCount / pageSize)
22
+ const start = (page - 1) * pageSize + 1
23
+ const end = Math.min(page * pageSize, totalCount)
24
+
25
+ lines.push(`# HAR Entries: ${filename}`)
26
+ lines.push("")
27
+ lines.push(`**Showing ${start}-${end} of ${totalCount} entries** (Page ${page}/${totalPages})`)
28
+
29
+ // Show active filters
30
+ if (filter) {
31
+ const filterParts: string[] = []
32
+ if (filter.url) filterParts.push(`url=${filter.url}`)
33
+ if (filter.method) {
34
+ const methods = Array.isArray(filter.method) ? filter.method.join(",") : filter.method
35
+ filterParts.push(`method=${methods}`)
36
+ }
37
+ if (filter.status !== undefined) {
38
+ if (typeof filter.status === "object") {
39
+ const { min, max } = filter.status
40
+ filterParts.push(`status=${min || ""}..${max || ""}`)
41
+ } else {
42
+ filterParts.push(`status=${filter.status}`)
43
+ }
44
+ }
45
+ if (filter.contentType) filterParts.push(`type=${filter.contentType}`)
46
+ if (filter.minDuration) filterParts.push(`minDuration=${filter.minDuration}ms`)
47
+ if (filter.hasError !== undefined) filterParts.push(`hasError=${filter.hasError}`)
48
+ if (filter.bodyContains) filterParts.push(`bodyContains="${filter.bodyContains}"`)
49
+
50
+ if (filterParts.length > 0) {
51
+ lines.push(`**Filter:** ${filterParts.join(", ")}`)
52
+ }
53
+ }
54
+
55
+ lines.push("")
56
+ lines.push("| # | Method | URL | Status | Size | Time |")
57
+ lines.push("|---|--------|-----|--------|------|------|")
58
+
59
+ let totalSize = 0
60
+ let totalTime = 0
61
+ let errorCount = 0
62
+
63
+ for (const { index, entry } of indexedEntries) {
64
+ const method = entry.request.method
65
+ const url = truncateUrl(getUrlPath(entry.request.url), 45)
66
+ const status = entry.response.status
67
+ const size = getResponseSize(entry)
68
+ const time = entry.time
69
+
70
+ totalSize += size
71
+ totalTime += time
72
+ if (isError(entry)) errorCount++
73
+
74
+ lines.push(`| ${index} | ${method} | ${url} | ${status} | ${formatBytes(size)} | ${formatDuration(time)} |`)
75
+ }
76
+
77
+ lines.push("")
78
+
79
+ // Quick stats
80
+ const avgTime = indexedEntries.length > 0 ? totalTime / indexedEntries.length : 0
81
+ lines.push(`**Quick Stats:** ${errorCount} errors, avg ${formatDuration(avgTime)}, total ${formatBytes(totalSize)}`)
82
+ lines.push("")
83
+
84
+ // Usage hints
85
+ lines.push('> Use `mode: "detail", entries: [INDEX]` to see full details')
86
+ lines.push('> Use `mode: "content", entries: [INDEX]` to see response body')
87
+
88
+ return lines.join("\n")
89
+ }
@@ -0,0 +1,150 @@
1
+ /**
2
+ * Size Analysis Formatter for Read HAR Tool
3
+ */
4
+
5
+ import type { HarFile } from "../../../types/har.ts"
6
+ import { formatBytes, getContentTypeCategory } from "../helpers.ts"
7
+
8
+ /**
9
+ * Formats size analysis mode output
10
+ */
11
+ export function formatSizeAnalysis(harFile: HarFile, filename: string): string {
12
+ const entries = harFile.log.entries
13
+ const lines: string[] = []
14
+
15
+ // Collect size data by type
16
+ const sizeByType = new Map<
17
+ string,
18
+ { count: number; totalSize: number; entries: { index: number; url: string; size: number }[] }
19
+ >()
20
+ let totalSize = 0
21
+ let totalTransferred = 0
22
+
23
+ for (let i = 0; i < entries.length; i++) {
24
+ const entry = entries[i]!
25
+ const contentType = getContentTypeCategory(entry.response.content.mimeType)
26
+ const size = entry.response.content.size
27
+ const transferred = entry.response.bodySize > 0 ? entry.response.bodySize : size
28
+
29
+ totalSize += size
30
+ totalTransferred += transferred
31
+
32
+ const existing = sizeByType.get(contentType) || { count: 0, totalSize: 0, entries: [] }
33
+ existing.count++
34
+ existing.totalSize += size
35
+ existing.entries.push({
36
+ index: i,
37
+ url: entry.request.url,
38
+ size,
39
+ })
40
+ sizeByType.set(contentType, existing)
41
+ }
42
+
43
+ lines.push(`# HAR Size Analysis: ${filename}`)
44
+ lines.push("")
45
+
46
+ // Overview
47
+ lines.push("## Overview")
48
+ lines.push(`- **Total Entries:** ${entries.length}`)
49
+ lines.push(`- **Total Size:** ${formatBytes(totalSize)}`)
50
+ lines.push(`- **Total Transferred:** ${formatBytes(totalTransferred)}`)
51
+ if (totalTransferred < totalSize) {
52
+ const savings = ((1 - totalTransferred / totalSize) * 100).toFixed(1)
53
+ lines.push(`- **Compression Savings:** ${savings}%`)
54
+ }
55
+ lines.push("")
56
+
57
+ // Size by Type
58
+ lines.push("## Size by Content Type")
59
+ lines.push("| Type | Count | Total Size | Avg Size | % of Total |")
60
+ lines.push("|------|-------|------------|----------|------------|")
61
+
62
+ const sortedTypes = Array.from(sizeByType.entries()).sort((a, b) => b[1].totalSize - a[1].totalSize)
63
+
64
+ for (const [type, data] of sortedTypes) {
65
+ const avgSize = data.totalSize / data.count
66
+ const percentage = ((data.totalSize / totalSize) * 100).toFixed(1)
67
+ lines.push(
68
+ `| ${type} | ${data.count} | ${formatBytes(data.totalSize)} | ${formatBytes(avgSize)} | ${percentage}% |`,
69
+ )
70
+ }
71
+ lines.push("")
72
+
73
+ // Largest resources
74
+ const allEntries = entries
75
+ .map((e, i) => ({
76
+ index: i,
77
+ url: e.request.url,
78
+ size: e.response.content.size,
79
+ type: getContentTypeCategory(e.response.content.mimeType),
80
+ }))
81
+ .sort((a, b) => b.size - a.size)
82
+
83
+ lines.push("## Largest Resources")
84
+ lines.push("| # | Type | Size | URL |")
85
+ lines.push("|---|------|------|-----|")
86
+
87
+ for (const entry of allEntries.slice(0, 10)) {
88
+ let url: string
89
+ try {
90
+ const parsed = new URL(entry.url)
91
+ url = parsed.pathname.slice(0, 40)
92
+ } catch {
93
+ url = entry.url.slice(0, 40)
94
+ }
95
+ lines.push(`| ${entry.index} | ${entry.type} | ${formatBytes(entry.size)} | ${url} |`)
96
+ }
97
+ lines.push("")
98
+
99
+ // Optimization Suggestions
100
+ lines.push("## Optimization Suggestions")
101
+
102
+ const suggestions: string[] = []
103
+
104
+ // Check for large images
105
+ const imageData = sizeByType.get("Image")
106
+ if (imageData && imageData.totalSize > 500 * 1024) {
107
+ const largeImages = imageData.entries.filter(e => e.size > 100 * 1024)
108
+ if (largeImages.length > 0) {
109
+ suggestions.push(
110
+ `- **Large Images:** ${largeImages.length} images over 100KB. Consider compression or WebP format.`,
111
+ )
112
+ }
113
+ }
114
+
115
+ // Check for large JS
116
+ const jsData = sizeByType.get("JS")
117
+ if (jsData && jsData.totalSize > 500 * 1024) {
118
+ suggestions.push(
119
+ `- **Large JavaScript:** ${formatBytes(jsData.totalSize)} total. Consider code splitting or lazy loading.`,
120
+ )
121
+ }
122
+
123
+ // Check for uncompressed responses
124
+ const largeUncompressed = entries.filter(
125
+ e => e.response.content.size > 10 * 1024 && e.response.bodySize >= e.response.content.size * 0.9,
126
+ )
127
+ if (largeUncompressed.length > 0) {
128
+ suggestions.push(
129
+ `- **Uncompressed Responses:** ${largeUncompressed.length} responses may benefit from gzip/brotli compression.`,
130
+ )
131
+ }
132
+
133
+ // Check for duplicate requests
134
+ const urlCounts = new Map<string, number>()
135
+ for (const entry of entries) {
136
+ urlCounts.set(entry.request.url, (urlCounts.get(entry.request.url) || 0) + 1)
137
+ }
138
+ const duplicates = Array.from(urlCounts.entries()).filter(([, count]) => count > 1)
139
+ if (duplicates.length > 0) {
140
+ suggestions.push(`- **Duplicate Requests:** ${duplicates.length} URLs requested multiple times. Consider caching.`)
141
+ }
142
+
143
+ if (suggestions.length === 0) {
144
+ suggestions.push("- No major optimization issues detected.")
145
+ }
146
+
147
+ lines.push(...suggestions)
148
+
149
+ return lines.join("\n")
150
+ }