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.
- package/README.md +261 -0
- package/package.json +38 -0
- package/src/index.ts +33 -0
- package/src/server.ts +28 -0
- package/src/tools/export-curl.ts +134 -0
- package/src/tools/index.ts +17 -0
- package/src/tools/read-har/filters.ts +109 -0
- package/src/tools/read-har/formatters/content.ts +90 -0
- package/src/tools/read-har/formatters/cookies.ts +164 -0
- package/src/tools/read-har/formatters/detail.ts +148 -0
- package/src/tools/read-har/formatters/index.ts +12 -0
- package/src/tools/read-har/formatters/list.ts +89 -0
- package/src/tools/read-har/formatters/size.ts +150 -0
- package/src/tools/read-har/formatters/stats.ts +129 -0
- package/src/tools/read-har/formatters/summary.ts +174 -0
- package/src/tools/read-har/formatters/timeline.ts +95 -0
- package/src/tools/read-har/helpers.ts +237 -0
- package/src/tools/read-har/index.ts +393 -0
- package/src/tools/read-har/repair.ts +277 -0
- package/src/tools/read-har/schema.ts +82 -0
- package/src/tools/read-har.ts +15 -0
- package/src/types/har-to-curl.d.ts +11 -0
- package/src/types/har.ts +386 -0
|
@@ -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
|
+
}
|