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,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Stats Formatter for Read HAR Tool
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type { HarFile } from "../../../types/har.ts"
|
|
6
|
+
import { formatDuration } from "../helpers.ts"
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Formats stats mode output - aggregate statistics by endpoint
|
|
10
|
+
*/
|
|
11
|
+
export function formatStats(harFile: HarFile, filename: string): string {
|
|
12
|
+
const entries = harFile.log.entries
|
|
13
|
+
const lines: string[] = []
|
|
14
|
+
|
|
15
|
+
// Group by endpoint pattern
|
|
16
|
+
const endpointStats = new Map<
|
|
17
|
+
string,
|
|
18
|
+
{
|
|
19
|
+
count: number
|
|
20
|
+
methods: Set<string>
|
|
21
|
+
durations: number[]
|
|
22
|
+
sizes: number[]
|
|
23
|
+
errors: number
|
|
24
|
+
statuses: Map<number, number>
|
|
25
|
+
}
|
|
26
|
+
>()
|
|
27
|
+
|
|
28
|
+
for (const entry of entries) {
|
|
29
|
+
try {
|
|
30
|
+
const url = new URL(entry.request.url)
|
|
31
|
+
// Create pattern by replacing IDs with *
|
|
32
|
+
const pathParts = url.pathname.split("/").filter(Boolean)
|
|
33
|
+
const patternParts = pathParts.map(part => {
|
|
34
|
+
if (/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(part)) return "*"
|
|
35
|
+
if (/^\d+$/.test(part)) return "*"
|
|
36
|
+
if (/^[0-9a-f]{24,}$/i.test(part)) return "*"
|
|
37
|
+
return part
|
|
38
|
+
})
|
|
39
|
+
const pattern = `${url.host}/${patternParts.join("/")}`
|
|
40
|
+
|
|
41
|
+
const existing = endpointStats.get(pattern) || {
|
|
42
|
+
count: 0,
|
|
43
|
+
methods: new Set<string>(),
|
|
44
|
+
durations: [],
|
|
45
|
+
sizes: [],
|
|
46
|
+
errors: 0,
|
|
47
|
+
statuses: new Map<number, number>(),
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
existing.count++
|
|
51
|
+
existing.methods.add(entry.request.method)
|
|
52
|
+
existing.durations.push(entry.time)
|
|
53
|
+
existing.sizes.push(entry.response.content.size)
|
|
54
|
+
if (entry.response.status >= 400) existing.errors++
|
|
55
|
+
existing.statuses.set(entry.response.status, (existing.statuses.get(entry.response.status) || 0) + 1)
|
|
56
|
+
|
|
57
|
+
endpointStats.set(pattern, existing)
|
|
58
|
+
} catch {
|
|
59
|
+
// Skip invalid URLs
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
lines.push(`# HAR Statistics: ${filename}`)
|
|
64
|
+
lines.push("")
|
|
65
|
+
lines.push(`**Total Entries:** ${entries.length}`)
|
|
66
|
+
lines.push(`**Unique Endpoints:** ${endpointStats.size}`)
|
|
67
|
+
lines.push("")
|
|
68
|
+
|
|
69
|
+
// Sort by count descending
|
|
70
|
+
const sorted = Array.from(endpointStats.entries()).sort((a, b) => b[1].count - a[1].count)
|
|
71
|
+
|
|
72
|
+
lines.push("## Endpoint Statistics")
|
|
73
|
+
lines.push("")
|
|
74
|
+
lines.push("| Endpoint | Count | Methods | Avg Time | P95 Time | Error Rate |")
|
|
75
|
+
lines.push("|----------|-------|---------|----------|----------|------------|")
|
|
76
|
+
|
|
77
|
+
for (const [pattern, stats] of sorted) {
|
|
78
|
+
const avgTime = stats.durations.reduce((a, b) => a + b, 0) / stats.durations.length
|
|
79
|
+
const sortedDurations = [...stats.durations].sort((a, b) => a - b)
|
|
80
|
+
const p95Idx = Math.floor(sortedDurations.length * 0.95)
|
|
81
|
+
const p95Time = sortedDurations[p95Idx] || sortedDurations[sortedDurations.length - 1] || 0
|
|
82
|
+
const errorRate = ((stats.errors / stats.count) * 100).toFixed(1)
|
|
83
|
+
const methods = Array.from(stats.methods).join(",")
|
|
84
|
+
|
|
85
|
+
const truncatedPattern = pattern.length > 40 ? `${pattern.slice(0, 37)}...` : pattern
|
|
86
|
+
lines.push(
|
|
87
|
+
`| ${truncatedPattern} | ${stats.count} | ${methods} | ${formatDuration(avgTime)} | ${formatDuration(p95Time)} | ${errorRate}% |`,
|
|
88
|
+
)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
lines.push("")
|
|
92
|
+
|
|
93
|
+
// Top slowest endpoints
|
|
94
|
+
const slowest = sorted
|
|
95
|
+
.map(([pattern, stats]) => ({
|
|
96
|
+
pattern,
|
|
97
|
+
avg: stats.durations.reduce((a, b) => a + b, 0) / stats.durations.length,
|
|
98
|
+
}))
|
|
99
|
+
.sort((a, b) => b.avg - a.avg)
|
|
100
|
+
.slice(0, 5)
|
|
101
|
+
|
|
102
|
+
if (slowest.length > 0) {
|
|
103
|
+
lines.push("## Slowest Endpoints (by avg)")
|
|
104
|
+
for (const { pattern, avg } of slowest) {
|
|
105
|
+
lines.push(`- ${pattern.slice(0, 50)}: ${formatDuration(avg)}`)
|
|
106
|
+
}
|
|
107
|
+
lines.push("")
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Highest error rate endpoints
|
|
111
|
+
const highestErrors = sorted
|
|
112
|
+
.filter(([, stats]) => stats.errors > 0)
|
|
113
|
+
.map(([pattern, stats]) => ({
|
|
114
|
+
pattern,
|
|
115
|
+
rate: (stats.errors / stats.count) * 100,
|
|
116
|
+
count: stats.errors,
|
|
117
|
+
}))
|
|
118
|
+
.sort((a, b) => b.rate - a.rate)
|
|
119
|
+
.slice(0, 5)
|
|
120
|
+
|
|
121
|
+
if (highestErrors.length > 0) {
|
|
122
|
+
lines.push("## Highest Error Rates")
|
|
123
|
+
for (const { pattern, rate, count } of highestErrors) {
|
|
124
|
+
lines.push(`- ${pattern.slice(0, 50)}: ${rate.toFixed(1)}% (${count} errors)`)
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
return lines.join("\n")
|
|
129
|
+
}
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Summary Formatter for Read HAR Tool
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type { HarFile } from "../../../types/har.ts"
|
|
6
|
+
import {
|
|
7
|
+
formatDuration,
|
|
8
|
+
getContentTypeCategory,
|
|
9
|
+
getUrlPath,
|
|
10
|
+
identifyUrlPatterns,
|
|
11
|
+
isError,
|
|
12
|
+
truncateUrl,
|
|
13
|
+
} from "../helpers.ts"
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Formats summary mode output
|
|
17
|
+
*/
|
|
18
|
+
export function formatSummary(harFile: HarFile, filename: string): string {
|
|
19
|
+
const log = harFile.log
|
|
20
|
+
const entries = log.entries
|
|
21
|
+
|
|
22
|
+
// Collect statistics
|
|
23
|
+
const methodCounts = new Map<string, number>()
|
|
24
|
+
const statusCounts = new Map<string, number>()
|
|
25
|
+
const typeCounts = new Map<string, number>()
|
|
26
|
+
const errorEntries: { status: number; url: string }[] = []
|
|
27
|
+
let totalDuration = 0
|
|
28
|
+
const durations: number[] = []
|
|
29
|
+
let slowestEntry: { index: number; url: string; duration: number } | null = null
|
|
30
|
+
|
|
31
|
+
for (let index = 0; index < entries.length; index++) {
|
|
32
|
+
const entry = entries[index]!
|
|
33
|
+
|
|
34
|
+
// Method counts
|
|
35
|
+
const method = entry.request.method
|
|
36
|
+
methodCounts.set(method, (methodCounts.get(method) || 0) + 1)
|
|
37
|
+
|
|
38
|
+
// Status counts (group by class)
|
|
39
|
+
const statusClass = `${Math.floor(entry.response.status / 100)}xx`
|
|
40
|
+
statusCounts.set(statusClass, (statusCounts.get(statusClass) || 0) + 1)
|
|
41
|
+
|
|
42
|
+
// Content type counts
|
|
43
|
+
const contentType = getContentTypeCategory(entry.response.content.mimeType)
|
|
44
|
+
typeCounts.set(contentType, (typeCounts.get(contentType) || 0) + 1)
|
|
45
|
+
|
|
46
|
+
// Errors
|
|
47
|
+
if (isError(entry)) {
|
|
48
|
+
errorEntries.push({ status: entry.response.status, url: getUrlPath(entry.request.url) })
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Duration stats
|
|
52
|
+
const duration = entry.time
|
|
53
|
+
totalDuration += duration
|
|
54
|
+
durations.push(duration)
|
|
55
|
+
|
|
56
|
+
if (!slowestEntry || duration > slowestEntry.duration) {
|
|
57
|
+
slowestEntry = { index, url: getUrlPath(entry.request.url), duration }
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Calculate percentiles
|
|
62
|
+
durations.sort((a, b) => a - b)
|
|
63
|
+
const avgDuration = entries.length > 0 ? totalDuration / entries.length : 0
|
|
64
|
+
const p95Index = Math.floor(durations.length * 0.95)
|
|
65
|
+
const p95Duration = durations[p95Index] || 0
|
|
66
|
+
|
|
67
|
+
// Group errors by status
|
|
68
|
+
const errorsByStatus = new Map<number, string[]>()
|
|
69
|
+
for (const error of errorEntries) {
|
|
70
|
+
const urls = errorsByStatus.get(error.status) || []
|
|
71
|
+
urls.push(error.url)
|
|
72
|
+
errorsByStatus.set(error.status, urls)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Get URL patterns
|
|
76
|
+
const urlPatterns = identifyUrlPatterns(entries)
|
|
77
|
+
const topPatterns = Array.from(urlPatterns.entries())
|
|
78
|
+
.sort((a, b) => b[1].count - a[1].count)
|
|
79
|
+
.slice(0, 10)
|
|
80
|
+
|
|
81
|
+
// Build output
|
|
82
|
+
const lines: string[] = []
|
|
83
|
+
|
|
84
|
+
lines.push(`# HAR Summary: ${filename}`)
|
|
85
|
+
lines.push("")
|
|
86
|
+
|
|
87
|
+
// File Info
|
|
88
|
+
lines.push("## File Info")
|
|
89
|
+
lines.push(`- **Version:** ${log.version}`)
|
|
90
|
+
lines.push(`- **Creator:** ${log.creator.name} ${log.creator.version}`)
|
|
91
|
+
const firstEntry = entries[0]
|
|
92
|
+
if (firstEntry) {
|
|
93
|
+
lines.push(`- **Captured:** ${firstEntry.startedDateTime}`)
|
|
94
|
+
}
|
|
95
|
+
lines.push(`- **Total Entries:** ${entries.length}`)
|
|
96
|
+
lines.push("")
|
|
97
|
+
|
|
98
|
+
// Pages
|
|
99
|
+
if (log.pages && log.pages.length > 0) {
|
|
100
|
+
lines.push("## Pages")
|
|
101
|
+
lines.push("| # | Title | Load Time |")
|
|
102
|
+
lines.push("|---|-------|-----------|")
|
|
103
|
+
log.pages.forEach((page, i) => {
|
|
104
|
+
const loadTime = page.pageTimings.onLoad ? formatDuration(page.pageTimings.onLoad) : "-"
|
|
105
|
+
lines.push(`| ${i + 1} | ${page.title} | ${loadTime} |`)
|
|
106
|
+
})
|
|
107
|
+
lines.push("")
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Request Statistics
|
|
111
|
+
lines.push("## Request Statistics")
|
|
112
|
+
|
|
113
|
+
// Methods
|
|
114
|
+
const methodStr = Array.from(methodCounts.entries())
|
|
115
|
+
.sort((a, b) => b[1] - a[1])
|
|
116
|
+
.map(([m, c]) => `${m} (${c})`)
|
|
117
|
+
.join(", ")
|
|
118
|
+
lines.push(`- **By Method:** ${methodStr}`)
|
|
119
|
+
|
|
120
|
+
// Status
|
|
121
|
+
const statusStr = Array.from(statusCounts.entries())
|
|
122
|
+
.sort((a, b) => a[0].localeCompare(b[0]))
|
|
123
|
+
.map(([s, c]) => `${s} (${c})`)
|
|
124
|
+
.join(", ")
|
|
125
|
+
lines.push(`- **By Status:** ${statusStr}`)
|
|
126
|
+
|
|
127
|
+
// Types
|
|
128
|
+
const typeStr = Array.from(typeCounts.entries())
|
|
129
|
+
.sort((a, b) => b[1] - a[1])
|
|
130
|
+
.map(([t, c]) => `${t} (${c})`)
|
|
131
|
+
.join(", ")
|
|
132
|
+
lines.push(`- **By Type:** ${typeStr}`)
|
|
133
|
+
lines.push("")
|
|
134
|
+
|
|
135
|
+
// Errors
|
|
136
|
+
if (errorsByStatus.size > 0) {
|
|
137
|
+
lines.push(`## Errors (${errorEntries.length} total)`)
|
|
138
|
+
lines.push("| Status | Count | Example URLs |")
|
|
139
|
+
lines.push("|--------|-------|--------------|")
|
|
140
|
+
for (const [status, urls] of Array.from(errorsByStatus.entries()).sort((a, b) => a[0] - b[0])) {
|
|
141
|
+
const exampleUrls = urls
|
|
142
|
+
.slice(0, 3)
|
|
143
|
+
.map(u => truncateUrl(u, 30))
|
|
144
|
+
.join(", ")
|
|
145
|
+
lines.push(`| ${status} | ${urls.length} | ${exampleUrls} |`)
|
|
146
|
+
}
|
|
147
|
+
lines.push("")
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Performance
|
|
151
|
+
lines.push("## Performance")
|
|
152
|
+
if (slowestEntry) {
|
|
153
|
+
lines.push(
|
|
154
|
+
`- **Slowest:** #${slowestEntry.index} ${truncateUrl(slowestEntry.url, 40)} (${formatDuration(slowestEntry.duration)})`,
|
|
155
|
+
)
|
|
156
|
+
}
|
|
157
|
+
lines.push(`- **Average:** ${formatDuration(avgDuration)}`)
|
|
158
|
+
lines.push(`- **P95:** ${formatDuration(p95Duration)}`)
|
|
159
|
+
lines.push("")
|
|
160
|
+
|
|
161
|
+
// Top URL Patterns
|
|
162
|
+
if (topPatterns.length > 0) {
|
|
163
|
+
lines.push("## Top URL Patterns")
|
|
164
|
+
lines.push("| Pattern | Count | Methods |")
|
|
165
|
+
lines.push("|---------|-------|---------|")
|
|
166
|
+
for (const [pattern, data] of topPatterns) {
|
|
167
|
+
const methods = Array.from(data.methods).join(", ")
|
|
168
|
+
lines.push(`| ${truncateUrl(pattern, 40)} | ${data.count} | ${methods} |`)
|
|
169
|
+
}
|
|
170
|
+
lines.push("")
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
return lines.join("\n")
|
|
174
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Timeline Formatter for Read HAR Tool
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type { HarFile } from "../../../types/har.ts"
|
|
6
|
+
import { formatDuration } from "../helpers.ts"
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Gets the bar character based on status code
|
|
10
|
+
*/
|
|
11
|
+
function getStatusBar(status: number, length: number): string {
|
|
12
|
+
let char: string
|
|
13
|
+
if (status < 300) char = "█"
|
|
14
|
+
else if (status < 400) char = "▓"
|
|
15
|
+
else if (status < 500) char = "░"
|
|
16
|
+
else char = "▒"
|
|
17
|
+
return char.repeat(length)
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Formats timeline mode output - text-based waterfall visualization
|
|
22
|
+
*/
|
|
23
|
+
export function formatTimeline(harFile: HarFile, filename: string, pageSize = 20, page = 1): string {
|
|
24
|
+
const entries = harFile.log.entries
|
|
25
|
+
const lines: string[] = []
|
|
26
|
+
|
|
27
|
+
if (entries.length === 0) {
|
|
28
|
+
return "# Timeline\n\nNo entries to display."
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Get time range
|
|
32
|
+
const startTimes = entries.map(e => new Date(e.startedDateTime).getTime())
|
|
33
|
+
const minTime = Math.min(...startTimes)
|
|
34
|
+
const endTimes = entries.map((e, i) => startTimes[i]! + e.time)
|
|
35
|
+
const maxTime = Math.max(...endTimes)
|
|
36
|
+
const totalDuration = maxTime - minTime
|
|
37
|
+
|
|
38
|
+
// Pagination
|
|
39
|
+
const start = (page - 1) * pageSize
|
|
40
|
+
const pageEntries = entries.slice(start, start + pageSize)
|
|
41
|
+
const totalPages = Math.ceil(entries.length / pageSize)
|
|
42
|
+
|
|
43
|
+
lines.push(`# HAR Timeline: ${filename}`)
|
|
44
|
+
lines.push("")
|
|
45
|
+
lines.push(
|
|
46
|
+
`**Duration:** ${formatDuration(totalDuration)} | **Entries:** ${entries.length} | **Page:** ${page}/${totalPages}`,
|
|
47
|
+
)
|
|
48
|
+
lines.push("")
|
|
49
|
+
|
|
50
|
+
// Width for the bar visualization
|
|
51
|
+
const barWidth = 40
|
|
52
|
+
|
|
53
|
+
lines.push("```")
|
|
54
|
+
lines.push(`${"Time".padEnd(8)} ${"Method".padEnd(7)} ${"Bar".padEnd(barWidth + 2)} ${"Duration".padEnd(10)} URL`)
|
|
55
|
+
lines.push(`${"-".repeat(8)} ${"-".repeat(7)} ${"-".repeat(barWidth + 2)} ${"-".repeat(10)} ${"-".repeat(30)}`)
|
|
56
|
+
|
|
57
|
+
for (let i = 0; i < pageEntries.length; i++) {
|
|
58
|
+
const entry = pageEntries[i]!
|
|
59
|
+
const entryStart = new Date(entry.startedDateTime).getTime()
|
|
60
|
+
const relativeStart = entryStart - minTime
|
|
61
|
+
const duration = entry.time
|
|
62
|
+
|
|
63
|
+
// Calculate bar position and width
|
|
64
|
+
const barStart = Math.floor((relativeStart / totalDuration) * barWidth)
|
|
65
|
+
const barLength = Math.max(1, Math.ceil((duration / totalDuration) * barWidth))
|
|
66
|
+
|
|
67
|
+
// Create the bar
|
|
68
|
+
const bar = " ".repeat(barStart) + getStatusBar(entry.response.status, barLength)
|
|
69
|
+
const paddedBar = `[${bar.padEnd(barWidth)}]`
|
|
70
|
+
|
|
71
|
+
// Format time offset
|
|
72
|
+
const timeOffset = formatDuration(relativeStart).padEnd(8)
|
|
73
|
+
|
|
74
|
+
// Method and URL
|
|
75
|
+
const method = entry.request.method.padEnd(7)
|
|
76
|
+
const durationStr = formatDuration(duration).padEnd(10)
|
|
77
|
+
|
|
78
|
+
// Truncate URL
|
|
79
|
+
let url: string
|
|
80
|
+
try {
|
|
81
|
+
const parsed = new URL(entry.request.url)
|
|
82
|
+
url = parsed.pathname.slice(0, 30)
|
|
83
|
+
} catch {
|
|
84
|
+
url = entry.request.url.slice(0, 30)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
lines.push(`${timeOffset} ${method} ${paddedBar} ${durationStr} ${url}`)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
lines.push("```")
|
|
91
|
+
lines.push("")
|
|
92
|
+
lines.push("**Legend:** █ = 2xx, ▓ = 3xx, ░ = 4xx, ▒ = 5xx")
|
|
93
|
+
|
|
94
|
+
return lines.join("\n")
|
|
95
|
+
}
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Helper Functions for Read HAR Tool
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type { HarEntry, HarTimings } from "../../types/har.ts"
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Formats bytes into human-readable string
|
|
9
|
+
*/
|
|
10
|
+
export function formatBytes(bytes: number): string {
|
|
11
|
+
if (bytes < 0) return "-"
|
|
12
|
+
if (bytes === 0) return "0B"
|
|
13
|
+
if (bytes < 1024) return `${bytes}B`
|
|
14
|
+
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`
|
|
15
|
+
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)}MB`
|
|
16
|
+
return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)}GB`
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Formats duration in milliseconds to human-readable string
|
|
21
|
+
*/
|
|
22
|
+
export function formatDuration(ms: number): string {
|
|
23
|
+
if (ms < 0) return "-"
|
|
24
|
+
if (ms < 1) return "<1ms"
|
|
25
|
+
if (ms < 1000) return `${Math.round(ms)}ms`
|
|
26
|
+
if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`
|
|
27
|
+
return `${(ms / 60000).toFixed(1)}min`
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Matches URL against a pattern (substring, glob, or regex)
|
|
32
|
+
*/
|
|
33
|
+
export function matchUrl(url: string, pattern: string): boolean {
|
|
34
|
+
// Regex pattern (starts with ~)
|
|
35
|
+
if (pattern.startsWith("~")) {
|
|
36
|
+
try {
|
|
37
|
+
const regex = new RegExp(pattern.slice(1), "i")
|
|
38
|
+
return regex.test(url)
|
|
39
|
+
} catch {
|
|
40
|
+
return false
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Glob pattern (contains *)
|
|
45
|
+
if (pattern.includes("*")) {
|
|
46
|
+
const regexPattern = pattern
|
|
47
|
+
.replace(/[.+^${}()|[\]\\]/g, "\\$&") // Escape special chars except *
|
|
48
|
+
.replace(/\*/g, ".*") // Convert * to .*
|
|
49
|
+
try {
|
|
50
|
+
const regex = new RegExp(`^${regexPattern}$`, "i")
|
|
51
|
+
return regex.test(url)
|
|
52
|
+
} catch {
|
|
53
|
+
return false
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Substring match (case-insensitive)
|
|
58
|
+
return url.toLowerCase().includes(pattern.toLowerCase())
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Matches status code against filter
|
|
63
|
+
*/
|
|
64
|
+
export function matchStatus(status: number, filter: number | string | { min?: number; max?: number }): boolean {
|
|
65
|
+
if (typeof filter === "number") {
|
|
66
|
+
return status === filter
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (typeof filter === "string") {
|
|
70
|
+
// Pattern like "2xx", "4xx", "5xx"
|
|
71
|
+
const match = filter.match(/^(\d)xx$/i)
|
|
72
|
+
if (match?.[1]) {
|
|
73
|
+
const firstDigit = Number.parseInt(match[1], 10)
|
|
74
|
+
return Math.floor(status / 100) === firstDigit
|
|
75
|
+
}
|
|
76
|
+
return false
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Range filter
|
|
80
|
+
const { min, max } = filter
|
|
81
|
+
if (min !== undefined && status < min) return false
|
|
82
|
+
if (max !== undefined && status > max) return false
|
|
83
|
+
return true
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Gets content type category from MIME type
|
|
88
|
+
*/
|
|
89
|
+
export function getContentTypeCategory(mimeType: string): string {
|
|
90
|
+
const type = mimeType.toLowerCase()
|
|
91
|
+
if (type.includes("json")) return "JSON"
|
|
92
|
+
if (type.includes("html")) return "HTML"
|
|
93
|
+
if (type.includes("javascript") || type.includes("ecmascript")) return "JS"
|
|
94
|
+
if (type.includes("css")) return "CSS"
|
|
95
|
+
if (type.includes("xml")) return "XML"
|
|
96
|
+
if (type.includes("image")) return "Image"
|
|
97
|
+
if (type.includes("font")) return "Font"
|
|
98
|
+
if (type.includes("text/plain")) return "Text"
|
|
99
|
+
if (type.includes("form")) return "Form"
|
|
100
|
+
if (type.includes("video")) return "Video"
|
|
101
|
+
if (type.includes("audio")) return "Audio"
|
|
102
|
+
return "Other"
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Truncates URL for display - shows domain + last path segment
|
|
107
|
+
*/
|
|
108
|
+
export function truncateUrl(url: string, maxLength = 60): string {
|
|
109
|
+
if (url.length <= maxLength) return url
|
|
110
|
+
try {
|
|
111
|
+
const parsed = new URL(url)
|
|
112
|
+
const pathParts = parsed.pathname.split("/").filter(Boolean)
|
|
113
|
+
const lastPart = pathParts[pathParts.length - 1] || ""
|
|
114
|
+
const domain = parsed.hostname
|
|
115
|
+
|
|
116
|
+
// Try: domain + /.../ + lastPart
|
|
117
|
+
const shortened = `${domain}/.../${lastPart}`
|
|
118
|
+
if (shortened.length <= maxLength) return shortened
|
|
119
|
+
|
|
120
|
+
// If still too long, truncate the last part
|
|
121
|
+
const availableForPath = maxLength - domain.length - 5 // 5 for "/.../"
|
|
122
|
+
if (availableForPath > 10) {
|
|
123
|
+
return `${domain}/.../${lastPart.slice(0, availableForPath)}`
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Fallback: just truncate
|
|
127
|
+
return `${url.slice(0, maxLength - 3)}...`
|
|
128
|
+
} catch {
|
|
129
|
+
return `${url.slice(0, maxLength - 3)}...`
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Gets the path portion of a URL for display
|
|
135
|
+
*/
|
|
136
|
+
export function getUrlPath(url: string): string {
|
|
137
|
+
try {
|
|
138
|
+
const parsed = new URL(url)
|
|
139
|
+
return parsed.pathname + parsed.search
|
|
140
|
+
} catch {
|
|
141
|
+
return url
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Checks if an entry is an error (4xx or 5xx status)
|
|
147
|
+
*/
|
|
148
|
+
export function isError(entry: HarEntry): boolean {
|
|
149
|
+
return entry.response.status >= 400
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Gets response size from entry
|
|
154
|
+
*/
|
|
155
|
+
export function getResponseSize(entry: HarEntry): number {
|
|
156
|
+
const bodySize = entry.response.bodySize
|
|
157
|
+
if (bodySize >= 0) return bodySize
|
|
158
|
+
return entry.response.content.size
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Calculates total timing from timings object
|
|
163
|
+
*/
|
|
164
|
+
export function getTotalTiming(timings: HarTimings): number {
|
|
165
|
+
let total = 0
|
|
166
|
+
if (timings.blocked && timings.blocked > 0) total += timings.blocked
|
|
167
|
+
if (timings.dns && timings.dns > 0) total += timings.dns
|
|
168
|
+
if (timings.connect && timings.connect > 0) total += timings.connect
|
|
169
|
+
if (timings.ssl && timings.ssl > 0) total += timings.ssl
|
|
170
|
+
total += timings.send > 0 ? timings.send : 0
|
|
171
|
+
total += timings.wait > 0 ? timings.wait : 0
|
|
172
|
+
total += timings.receive > 0 ? timings.receive : 0
|
|
173
|
+
return total
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Identifies URL patterns in entries
|
|
178
|
+
*/
|
|
179
|
+
export function identifyUrlPatterns(entries: HarEntry[]): Map<string, { count: number; methods: Set<string> }> {
|
|
180
|
+
const patterns = new Map<string, { count: number; methods: Set<string> }>()
|
|
181
|
+
|
|
182
|
+
for (const entry of entries) {
|
|
183
|
+
try {
|
|
184
|
+
const url = new URL(entry.request.url)
|
|
185
|
+
// Create pattern by generalizing path segments
|
|
186
|
+
const pathParts = url.pathname.split("/").filter(Boolean)
|
|
187
|
+
const patternParts = pathParts.map(part => {
|
|
188
|
+
// Replace UUIDs, numbers, and hex strings with wildcards
|
|
189
|
+
if (/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(part)) return "*"
|
|
190
|
+
if (/^\d+$/.test(part)) return "*"
|
|
191
|
+
if (/^[0-9a-f]{24,}$/i.test(part)) return "*"
|
|
192
|
+
return part
|
|
193
|
+
})
|
|
194
|
+
const pattern = `/${patternParts.join("/")}${url.pathname.endsWith("/") ? "/" : ""}`
|
|
195
|
+
|
|
196
|
+
const existing = patterns.get(pattern)
|
|
197
|
+
if (existing) {
|
|
198
|
+
existing.count++
|
|
199
|
+
existing.methods.add(entry.request.method)
|
|
200
|
+
} else {
|
|
201
|
+
patterns.set(pattern, { count: 1, methods: new Set([entry.request.method]) })
|
|
202
|
+
}
|
|
203
|
+
} catch {
|
|
204
|
+
// Skip invalid URLs
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
return patterns
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Gets appropriate code fence language for MIME type
|
|
213
|
+
*/
|
|
214
|
+
export function getCodeFenceLanguage(mimeType: string): string {
|
|
215
|
+
const type = mimeType.toLowerCase()
|
|
216
|
+
if (type.includes("json")) return "json"
|
|
217
|
+
if (type.includes("html")) return "html"
|
|
218
|
+
if (type.includes("xml")) return "xml"
|
|
219
|
+
if (type.includes("javascript")) return "javascript"
|
|
220
|
+
if (type.includes("css")) return "css"
|
|
221
|
+
return ""
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Formats content for display (pretty-print JSON if applicable)
|
|
226
|
+
*/
|
|
227
|
+
export function formatContentForDisplay(content: string, mimeType: string): string {
|
|
228
|
+
if (mimeType.toLowerCase().includes("json")) {
|
|
229
|
+
try {
|
|
230
|
+
const parsed = JSON.parse(content)
|
|
231
|
+
return JSON.stringify(parsed, null, 2)
|
|
232
|
+
} catch {
|
|
233
|
+
return content
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
return content
|
|
237
|
+
}
|