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,393 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Read HAR Tool
|
|
3
|
+
*
|
|
4
|
+
* Reads and analyzes HAR (HTTP Archive) files with multiple output modes
|
|
5
|
+
* and filtering capabilities. Returns LLM-friendly markdown output.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import * as fs from "node:fs/promises"
|
|
9
|
+
import * as path from "node:path"
|
|
10
|
+
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"
|
|
11
|
+
import { z } from "zod"
|
|
12
|
+
import { HarFileSchema } from "../../types/har.ts"
|
|
13
|
+
import { getDetailedJsonError, repairTruncatedHar } from "./repair.ts"
|
|
14
|
+
|
|
15
|
+
// Re-export schema and types
|
|
16
|
+
export { readHarSchema, type ReadHarInput, type ReadHarInputRaw } from "./schema.ts"
|
|
17
|
+
import type { ReadHarInput } from "./schema.ts"
|
|
18
|
+
|
|
19
|
+
// Re-export helper functions
|
|
20
|
+
export {
|
|
21
|
+
formatBytes,
|
|
22
|
+
formatDuration,
|
|
23
|
+
matchUrl,
|
|
24
|
+
matchStatus,
|
|
25
|
+
getContentTypeCategory,
|
|
26
|
+
truncateUrl,
|
|
27
|
+
getUrlPath,
|
|
28
|
+
isError,
|
|
29
|
+
getResponseSize,
|
|
30
|
+
getTotalTiming,
|
|
31
|
+
identifyUrlPatterns,
|
|
32
|
+
getCodeFenceLanguage,
|
|
33
|
+
formatContentForDisplay,
|
|
34
|
+
} from "./helpers.ts"
|
|
35
|
+
import { getResponseSize } from "./helpers.ts"
|
|
36
|
+
|
|
37
|
+
// Re-export filter types and functions
|
|
38
|
+
export { type IndexedEntry, filterEntries, sortEntries } from "./filters.ts"
|
|
39
|
+
import { type IndexedEntry, filterEntries, sortEntries } from "./filters.ts"
|
|
40
|
+
|
|
41
|
+
// Re-export formatters
|
|
42
|
+
export {
|
|
43
|
+
formatSummary,
|
|
44
|
+
formatList,
|
|
45
|
+
formatEntryDetail,
|
|
46
|
+
formatEntryContent,
|
|
47
|
+
formatTimeline,
|
|
48
|
+
formatStats,
|
|
49
|
+
formatSizeAnalysis,
|
|
50
|
+
formatCookiesAnalysis,
|
|
51
|
+
} from "./formatters/index.ts"
|
|
52
|
+
import {
|
|
53
|
+
formatCookiesAnalysis,
|
|
54
|
+
formatEntryContent,
|
|
55
|
+
formatEntryDetail,
|
|
56
|
+
formatList,
|
|
57
|
+
formatSizeAnalysis,
|
|
58
|
+
formatStats,
|
|
59
|
+
formatSummary,
|
|
60
|
+
formatTimeline,
|
|
61
|
+
} from "./formatters/index.ts"
|
|
62
|
+
|
|
63
|
+
// ============================================================================
|
|
64
|
+
// Main Handler
|
|
65
|
+
// ============================================================================
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Handler function for the read_har tool.
|
|
69
|
+
*/
|
|
70
|
+
export async function readHarHandler(input: ReadHarInput): Promise<string> {
|
|
71
|
+
// Read and parse the HAR file
|
|
72
|
+
const filePath = input.path
|
|
73
|
+
let fileContent: string
|
|
74
|
+
|
|
75
|
+
try {
|
|
76
|
+
fileContent = await fs.readFile(filePath, "utf-8")
|
|
77
|
+
} catch (err) {
|
|
78
|
+
const error = err as { code?: string; message: string }
|
|
79
|
+
if (error.code === "ENOENT") {
|
|
80
|
+
throw new Error(`HAR file not found: ${filePath}`)
|
|
81
|
+
}
|
|
82
|
+
if (error.code === "EACCES") {
|
|
83
|
+
throw new Error(`Permission denied reading HAR file: ${filePath}`)
|
|
84
|
+
}
|
|
85
|
+
throw new Error(`Failed to read HAR file: ${error.message}`)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
let harData: unknown
|
|
89
|
+
let repairWarning = ""
|
|
90
|
+
try {
|
|
91
|
+
harData = JSON.parse(fileContent)
|
|
92
|
+
} catch (jsonError) {
|
|
93
|
+
// Try to repair truncated HAR files
|
|
94
|
+
const errorDetails = getDetailedJsonError(jsonError, fileContent)
|
|
95
|
+
const repairResult = repairTruncatedHar(fileContent, errorDetails)
|
|
96
|
+
|
|
97
|
+
if (repairResult.success && repairResult.repairedJson) {
|
|
98
|
+
harData = JSON.parse(repairResult.repairedJson)
|
|
99
|
+
repairWarning = `\n\n> **Warning**: HAR file was truncated. Recovered ${repairResult.entriesRecovered} entries using ${repairResult.repairMethod} repair method. Some entries may be missing.\n`
|
|
100
|
+
} else {
|
|
101
|
+
throw new Error(`Invalid JSON in HAR file: ${errorDetails}`)
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Validate HAR structure
|
|
106
|
+
const parseResult = HarFileSchema.safeParse(harData)
|
|
107
|
+
if (!parseResult.success) {
|
|
108
|
+
const issues = parseResult.error.issues
|
|
109
|
+
.slice(0, 3)
|
|
110
|
+
.map(i => i.message)
|
|
111
|
+
.join("; ")
|
|
112
|
+
throw new Error(`Invalid HAR file format: ${issues}`)
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const harFile = parseResult.data
|
|
116
|
+
const filename = path.basename(filePath)
|
|
117
|
+
|
|
118
|
+
// Create indexed entries
|
|
119
|
+
let indexedEntries: IndexedEntry[] = harFile.log.entries.map((entry, index) => ({
|
|
120
|
+
index,
|
|
121
|
+
entry,
|
|
122
|
+
}))
|
|
123
|
+
|
|
124
|
+
// Apply filters
|
|
125
|
+
indexedEntries = filterEntries(indexedEntries, input.filter)
|
|
126
|
+
|
|
127
|
+
// Apply sorting
|
|
128
|
+
indexedEntries = sortEntries(indexedEntries, input.sort)
|
|
129
|
+
|
|
130
|
+
// Mode-specific handling
|
|
131
|
+
const mode = input.mode
|
|
132
|
+
const format = input.format ?? "markdown"
|
|
133
|
+
|
|
134
|
+
// Helper to add repair warning to output
|
|
135
|
+
const addWarning = (output: string): string => {
|
|
136
|
+
if (repairWarning && format === "markdown") {
|
|
137
|
+
return repairWarning + output
|
|
138
|
+
}
|
|
139
|
+
return output
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
if (mode === "summary") {
|
|
143
|
+
// Summary doesn't use pagination, operates on all entries
|
|
144
|
+
if (format === "json") {
|
|
145
|
+
const methodCounts: Record<string, number> = {}
|
|
146
|
+
const statusCounts: Record<string, number> = {}
|
|
147
|
+
for (const entry of harFile.log.entries) {
|
|
148
|
+
methodCounts[entry.request.method] = (methodCounts[entry.request.method] || 0) + 1
|
|
149
|
+
const statusClass = `${Math.floor(entry.response.status / 100)}xx`
|
|
150
|
+
statusCounts[statusClass] = (statusCounts[statusClass] || 0) + 1
|
|
151
|
+
}
|
|
152
|
+
const jsonData = {
|
|
153
|
+
file: filename,
|
|
154
|
+
mode: "summary",
|
|
155
|
+
version: harFile.log.version,
|
|
156
|
+
creator: harFile.log.creator,
|
|
157
|
+
totalEntries: harFile.log.entries.length,
|
|
158
|
+
pages: harFile.log.pages?.length || 0,
|
|
159
|
+
methods: methodCounts,
|
|
160
|
+
statuses: statusCounts,
|
|
161
|
+
}
|
|
162
|
+
return JSON.stringify(jsonData, null, 2)
|
|
163
|
+
}
|
|
164
|
+
return addWarning(formatSummary(harFile, filename))
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
if (mode === "list") {
|
|
168
|
+
// Apply pagination
|
|
169
|
+
const page = input.page
|
|
170
|
+
const pageSize = input.pageSize
|
|
171
|
+
const totalCount = indexedEntries.length
|
|
172
|
+
const start = (page - 1) * pageSize
|
|
173
|
+
const paginatedEntries = indexedEntries.slice(start, start + pageSize)
|
|
174
|
+
|
|
175
|
+
if (format === "json") {
|
|
176
|
+
const jsonData = {
|
|
177
|
+
file: filename,
|
|
178
|
+
mode: "list",
|
|
179
|
+
pagination: { page, pageSize, total: totalCount },
|
|
180
|
+
entries: paginatedEntries.map(({ index, entry }) => ({
|
|
181
|
+
index,
|
|
182
|
+
method: entry.request.method,
|
|
183
|
+
url: entry.request.url,
|
|
184
|
+
status: entry.response.status,
|
|
185
|
+
size: getResponseSize(entry),
|
|
186
|
+
time: entry.time,
|
|
187
|
+
})),
|
|
188
|
+
}
|
|
189
|
+
return JSON.stringify(jsonData, null, 2)
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
return addWarning(formatList(paginatedEntries, filename, page, pageSize, totalCount, input.filter))
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
if (mode === "detail") {
|
|
196
|
+
// Detail mode - show specific entries or paginated list
|
|
197
|
+
const include = {
|
|
198
|
+
headers: input.include?.headers ?? false,
|
|
199
|
+
cookies: input.include?.cookies ?? false,
|
|
200
|
+
timing: input.include?.timing ?? true,
|
|
201
|
+
queryParams: input.include?.queryParams ?? true,
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
if (input.entries && input.entries.length > 0) {
|
|
205
|
+
// Show specific entries
|
|
206
|
+
const results: string[] = []
|
|
207
|
+
|
|
208
|
+
for (const idx of input.entries) {
|
|
209
|
+
const found = indexedEntries.find(ie => ie.index === idx)
|
|
210
|
+
if (found) {
|
|
211
|
+
results.push(formatEntryDetail(found.index, found.entry, include))
|
|
212
|
+
} else {
|
|
213
|
+
results.push(`# Entry #${idx}\n\n> Entry not found or filtered out`)
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
return addWarning(results.join("\n\n---\n\n"))
|
|
218
|
+
}
|
|
219
|
+
// Paginate and show first entry on page
|
|
220
|
+
const page = input.page
|
|
221
|
+
const pageSize = input.pageSize
|
|
222
|
+
const start = (page - 1) * pageSize
|
|
223
|
+
const paginatedEntries = indexedEntries.slice(start, start + pageSize)
|
|
224
|
+
|
|
225
|
+
if (paginatedEntries.length === 0) {
|
|
226
|
+
return addWarning("# No entries found\n\nNo entries match the current filter criteria.")
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
const results: string[] = []
|
|
230
|
+
for (const ie of paginatedEntries) {
|
|
231
|
+
results.push(formatEntryDetail(ie.index, ie.entry, include))
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const header = `**Showing entries ${start + 1}-${start + paginatedEntries.length} of ${indexedEntries.length}**\n\n`
|
|
235
|
+
return addWarning(header + results.join("\n\n---\n\n"))
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
if (mode === "content") {
|
|
239
|
+
// Content mode - requires entries parameter
|
|
240
|
+
if (!input.entries || input.entries.length === 0) {
|
|
241
|
+
throw new Error('Content mode requires the "entries" parameter with at least one entry index')
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const options = {
|
|
245
|
+
includeRequestBody: input.contentOptions?.includeRequestBody ?? false,
|
|
246
|
+
includeResponseBody: input.contentOptions?.includeResponseBody ?? true,
|
|
247
|
+
maxContentLength: input.contentOptions?.maxContentLength ?? 50000,
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
const results: string[] = []
|
|
251
|
+
|
|
252
|
+
for (const idx of input.entries) {
|
|
253
|
+
const found = harFile.log.entries[idx]
|
|
254
|
+
if (found) {
|
|
255
|
+
results.push(formatEntryContent(idx, found, options))
|
|
256
|
+
} else {
|
|
257
|
+
results.push(`# Entry #${idx}\n\n> Entry index out of range (0-${harFile.log.entries.length - 1})`)
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
return addWarning(results.join("\n\n---\n\n"))
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
if (mode === "timeline") {
|
|
265
|
+
return addWarning(formatTimeline(harFile, filename, input.pageSize, input.page))
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
if (mode === "cookies") {
|
|
269
|
+
return addWarning(formatCookiesAnalysis(harFile, filename))
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
if (mode === "size") {
|
|
273
|
+
return addWarning(formatSizeAnalysis(harFile, filename))
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
if (mode === "stats") {
|
|
277
|
+
return addWarning(formatStats(harFile, filename))
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
throw new Error(`Unknown mode: ${mode}`)
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// ============================================================================
|
|
284
|
+
// Tool Registration
|
|
285
|
+
// ============================================================================
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* Registers the read_har tool with the MCP server.
|
|
289
|
+
*/
|
|
290
|
+
export function registerReadHarTool(server: McpServer): void {
|
|
291
|
+
server.tool(
|
|
292
|
+
"read_har",
|
|
293
|
+
"Reads and analyzes HAR (HTTP Archive) files. Supports multiple output modes: summary (overview stats), list (paginated entries), detail (full metadata), and content (with response bodies). Includes filtering, sorting, and pagination.",
|
|
294
|
+
{
|
|
295
|
+
path: z.string().describe("Path to the HAR file"),
|
|
296
|
+
|
|
297
|
+
mode: z
|
|
298
|
+
.enum(["summary", "list", "detail", "content", "timeline", "cookies", "size", "stats"])
|
|
299
|
+
.default("summary")
|
|
300
|
+
.describe(
|
|
301
|
+
"Output mode: summary (overview), list (entries), detail (full metadata), content (with body), timeline (waterfall visualization), cookies (cookie propagation tracking), size (payload size analysis), stats (aggregate by endpoint)",
|
|
302
|
+
),
|
|
303
|
+
|
|
304
|
+
page: z.number().int().min(1).default(1).describe("Page number for pagination"),
|
|
305
|
+
pageSize: z.number().int().min(1).max(100).default(20).describe("Number of entries per page"),
|
|
306
|
+
|
|
307
|
+
entries: z
|
|
308
|
+
.array(z.number().int().min(0))
|
|
309
|
+
.max(10)
|
|
310
|
+
.optional()
|
|
311
|
+
.describe("Specific entry indexes to return (required for content mode)"),
|
|
312
|
+
|
|
313
|
+
filter: z
|
|
314
|
+
.object({
|
|
315
|
+
url: z.string().optional().describe("URL pattern (substring, glob with *, or ~regex)"),
|
|
316
|
+
method: z
|
|
317
|
+
.union([z.string(), z.array(z.string())])
|
|
318
|
+
.optional()
|
|
319
|
+
.describe("HTTP method(s) to filter"),
|
|
320
|
+
status: z
|
|
321
|
+
.union([
|
|
322
|
+
z.number(),
|
|
323
|
+
z.string().regex(/^\d{1}xx$/i),
|
|
324
|
+
z.object({ min: z.number().optional(), max: z.number().optional() }),
|
|
325
|
+
])
|
|
326
|
+
.optional()
|
|
327
|
+
.describe("Status code, pattern (e.g., 4xx), or range"),
|
|
328
|
+
contentType: z.string().optional().describe("Response content type filter"),
|
|
329
|
+
minDuration: z.number().optional().describe("Minimum request duration in ms"),
|
|
330
|
+
hasError: z.boolean().optional().describe("Filter to only errors (4xx/5xx)"),
|
|
331
|
+
bodyContains: z.string().optional().describe("Filter entries whose response body contains this string"),
|
|
332
|
+
})
|
|
333
|
+
.optional()
|
|
334
|
+
.describe("Filter criteria for entries"),
|
|
335
|
+
|
|
336
|
+
include: z
|
|
337
|
+
.object({
|
|
338
|
+
headers: z.boolean().default(false).describe("Include headers in detail mode"),
|
|
339
|
+
cookies: z.boolean().default(false).describe("Include cookies in detail mode"),
|
|
340
|
+
timing: z.boolean().default(true).describe("Include timing breakdown in detail mode"),
|
|
341
|
+
queryParams: z.boolean().default(true).describe("Include query params in detail mode"),
|
|
342
|
+
})
|
|
343
|
+
.optional()
|
|
344
|
+
.describe("Options for detail mode"),
|
|
345
|
+
|
|
346
|
+
contentOptions: z
|
|
347
|
+
.object({
|
|
348
|
+
includeRequestBody: z.boolean().default(false).describe("Include request body in content mode"),
|
|
349
|
+
includeResponseBody: z.boolean().default(true).describe("Include response body in content mode"),
|
|
350
|
+
maxContentLength: z.number().default(50000).describe("Max content length before truncation"),
|
|
351
|
+
})
|
|
352
|
+
.optional()
|
|
353
|
+
.describe("Options for content mode"),
|
|
354
|
+
|
|
355
|
+
sort: z
|
|
356
|
+
.object({
|
|
357
|
+
by: z.enum(["index", "time", "duration", "size", "status"]).default("index").describe("Field to sort by"),
|
|
358
|
+
order: z.enum(["asc", "desc"]).default("asc").describe("Sort order"),
|
|
359
|
+
})
|
|
360
|
+
.optional()
|
|
361
|
+
.describe("Sorting options"),
|
|
362
|
+
|
|
363
|
+
format: z
|
|
364
|
+
.enum(["markdown", "json"])
|
|
365
|
+
.default("markdown")
|
|
366
|
+
.describe("Output format: markdown (default) or json for programmatic use"),
|
|
367
|
+
},
|
|
368
|
+
async args => {
|
|
369
|
+
try {
|
|
370
|
+
const result = await readHarHandler(args as ReadHarInput)
|
|
371
|
+
return {
|
|
372
|
+
content: [
|
|
373
|
+
{
|
|
374
|
+
type: "text" as const,
|
|
375
|
+
text: result,
|
|
376
|
+
},
|
|
377
|
+
],
|
|
378
|
+
}
|
|
379
|
+
} catch (err) {
|
|
380
|
+
const error = err as Error
|
|
381
|
+
return {
|
|
382
|
+
content: [
|
|
383
|
+
{
|
|
384
|
+
type: "text" as const,
|
|
385
|
+
text: `Error: ${error.message}`,
|
|
386
|
+
},
|
|
387
|
+
],
|
|
388
|
+
isError: true,
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
},
|
|
392
|
+
)
|
|
393
|
+
}
|
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HAR File Repair Utilities
|
|
3
|
+
*
|
|
4
|
+
* Handles truncated or malformed HAR files from browser exports.
|
|
5
|
+
* Chrome and other browsers sometimes export incomplete HAR files,
|
|
6
|
+
* especially with large response bodies (like base64-encoded content).
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export interface RepairResult {
|
|
10
|
+
success: boolean
|
|
11
|
+
repairedJson?: string
|
|
12
|
+
entriesRecovered?: number
|
|
13
|
+
originalError: string
|
|
14
|
+
repairMethod?: string
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Attempts to repair a truncated HAR JSON string.
|
|
19
|
+
*
|
|
20
|
+
* HAR files exported from Chrome can be truncated mid-content, especially
|
|
21
|
+
* when dealing with large base64-encoded response bodies. This function
|
|
22
|
+
* attempts to find the last complete entry and close the JSON structure.
|
|
23
|
+
*
|
|
24
|
+
* @param jsonStr - The potentially truncated JSON string
|
|
25
|
+
* @param originalError - The original JSON parse error message
|
|
26
|
+
* @returns RepairResult with success status and repaired JSON if successful
|
|
27
|
+
*/
|
|
28
|
+
export function repairTruncatedHar(jsonStr: string, originalError: string): RepairResult {
|
|
29
|
+
// First, try to parse as-is (maybe it's valid)
|
|
30
|
+
try {
|
|
31
|
+
JSON.parse(jsonStr)
|
|
32
|
+
return { success: true, repairedJson: jsonStr, originalError }
|
|
33
|
+
} catch {
|
|
34
|
+
// Continue with repair attempts
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Strategy 1: Chrome-specific - find entries by _workerRespondWithSettled field
|
|
38
|
+
// Chrome DevTools HAR always ends timings with this field
|
|
39
|
+
const result1 = tryRepairByChromeTimings(jsonStr, originalError)
|
|
40
|
+
if (result1.success) {
|
|
41
|
+
return result1
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Strategy 2: Find the last complete entry by looking for "timings" object endings
|
|
45
|
+
// HAR entries end with a "timings" object, so we can find complete entries this way
|
|
46
|
+
const result2 = tryRepairByTimings(jsonStr, originalError)
|
|
47
|
+
if (result2.success) {
|
|
48
|
+
return result2
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Strategy 3: Find last complete entry boundary (},{ pattern between entries)
|
|
52
|
+
const result3 = tryRepairByEntryBoundary(jsonStr, originalError)
|
|
53
|
+
if (result3.success) {
|
|
54
|
+
return result3
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Strategy 4: Binary search for a valid truncation point
|
|
58
|
+
const result4 = tryRepairByBinarySearch(jsonStr, originalError)
|
|
59
|
+
if (result4.success) {
|
|
60
|
+
return result4
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return {
|
|
64
|
+
success: false,
|
|
65
|
+
originalError,
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Chrome DevTools specific repair - looks for _workerRespondWithSettled field.
|
|
71
|
+
* Chrome's HAR format always ends timings with this field.
|
|
72
|
+
*/
|
|
73
|
+
function tryRepairByChromeTimings(jsonStr: string, originalError: string): RepairResult {
|
|
74
|
+
// Chrome's timings end with _workerRespondWithSettled, followed by } (timings) and } (entry)
|
|
75
|
+
const entryEndPattern = /"_workerRespondWithSettled"\s*:\s*[\d.-]+\s*\}\s*\}/g
|
|
76
|
+
const matches = [...jsonStr.matchAll(entryEndPattern)]
|
|
77
|
+
|
|
78
|
+
if (matches.length === 0) {
|
|
79
|
+
return { success: false, originalError }
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Try from the last match backwards
|
|
83
|
+
for (let i = matches.length - 1; i >= Math.max(0, matches.length - 20); i--) {
|
|
84
|
+
const match = matches[i]
|
|
85
|
+
if (!match || match.index === undefined) continue
|
|
86
|
+
|
|
87
|
+
const pos = match.index + match[0].length
|
|
88
|
+
// HAR structure: { log: { entries: [...] } }
|
|
89
|
+
// After entry }, we close: entries ], log }, root }
|
|
90
|
+
const testStr = `${jsonStr.substring(0, pos)}]}}`
|
|
91
|
+
|
|
92
|
+
try {
|
|
93
|
+
const parsed = JSON.parse(testStr)
|
|
94
|
+
return {
|
|
95
|
+
success: true,
|
|
96
|
+
repairedJson: testStr,
|
|
97
|
+
entriesRecovered: parsed.log?.entries?.length ?? 0,
|
|
98
|
+
originalError,
|
|
99
|
+
repairMethod: "chrome-timings",
|
|
100
|
+
}
|
|
101
|
+
} catch {
|
|
102
|
+
// Continue to next candidate
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return { success: false, originalError }
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Try to repair by finding the last complete "timings" object.
|
|
111
|
+
* Entries in HAR format end with a "timings" object containing timing data.
|
|
112
|
+
*/
|
|
113
|
+
function tryRepairByTimings(jsonStr: string, originalError: string): RepairResult {
|
|
114
|
+
// Find all complete timings objects
|
|
115
|
+
// Pattern matches "timings":{...} where the inner content has no nested braces
|
|
116
|
+
const timingsPattern = /"timings"\s*:\s*\{[^{}]*\}/g
|
|
117
|
+
const matches = [...jsonStr.matchAll(timingsPattern)]
|
|
118
|
+
|
|
119
|
+
if (matches.length === 0) {
|
|
120
|
+
return { success: false, originalError }
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Try from the last match backwards
|
|
124
|
+
for (let i = matches.length - 1; i >= Math.max(0, matches.length - 20); i--) {
|
|
125
|
+
const match = matches[i]
|
|
126
|
+
if (!match || match.index === undefined) continue
|
|
127
|
+
|
|
128
|
+
let pos = match.index + match[0].length
|
|
129
|
+
|
|
130
|
+
// Skip whitespace after timings
|
|
131
|
+
while (pos < jsonStr.length && /\s/.test(jsonStr[pos] ?? "")) {
|
|
132
|
+
pos++
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// After timings should come } to close the entry
|
|
136
|
+
if (jsonStr[pos] === "}") {
|
|
137
|
+
// Try closing the structure: entry } + entries ] + log } + root }
|
|
138
|
+
const testStr = `${jsonStr.substring(0, pos + 1)}]}}`
|
|
139
|
+
try {
|
|
140
|
+
const parsed = JSON.parse(testStr)
|
|
141
|
+
return {
|
|
142
|
+
success: true,
|
|
143
|
+
repairedJson: testStr,
|
|
144
|
+
entriesRecovered: parsed.log?.entries?.length ?? 0,
|
|
145
|
+
originalError,
|
|
146
|
+
repairMethod: "timings-boundary",
|
|
147
|
+
}
|
|
148
|
+
} catch {
|
|
149
|
+
// Continue to next candidate
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
return { success: false, originalError }
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Try to repair by finding entry boundaries (the pattern between entries).
|
|
159
|
+
*/
|
|
160
|
+
function tryRepairByEntryBoundary(jsonStr: string, originalError: string): RepairResult {
|
|
161
|
+
// Look for patterns that indicate entry boundaries
|
|
162
|
+
// Entries are separated by },{ or },"startedDateTime"
|
|
163
|
+
const boundaryPattern = /\}\s*,\s*\{/g
|
|
164
|
+
const boundaries: number[] = []
|
|
165
|
+
|
|
166
|
+
let match: RegExpExecArray | null = boundaryPattern.exec(jsonStr)
|
|
167
|
+
while (match !== null) {
|
|
168
|
+
// Position after the closing brace of the completed entry
|
|
169
|
+
boundaries.push(match.index + 1)
|
|
170
|
+
match = boundaryPattern.exec(jsonStr)
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
if (boundaries.length === 0) {
|
|
174
|
+
return { success: false, originalError }
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Try from the end backwards
|
|
178
|
+
for (let i = boundaries.length - 1; i >= Math.max(0, boundaries.length - 50); i--) {
|
|
179
|
+
const pos = boundaries[i]
|
|
180
|
+
if (pos === undefined) continue
|
|
181
|
+
const testStr = `${jsonStr.substring(0, pos)}]}}` // ] closes entries, }} closes log and root
|
|
182
|
+
try {
|
|
183
|
+
const parsed = JSON.parse(testStr)
|
|
184
|
+
return {
|
|
185
|
+
success: true,
|
|
186
|
+
repairedJson: testStr,
|
|
187
|
+
entriesRecovered: parsed.log?.entries?.length ?? 0,
|
|
188
|
+
originalError,
|
|
189
|
+
repairMethod: "entry-boundary",
|
|
190
|
+
}
|
|
191
|
+
} catch {
|
|
192
|
+
// Continue to next candidate
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
return { success: false, originalError }
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Binary search for a valid truncation point.
|
|
201
|
+
* This is slower but more thorough.
|
|
202
|
+
*/
|
|
203
|
+
function tryRepairByBinarySearch(jsonStr: string, originalError: string): RepairResult {
|
|
204
|
+
// Find all positions where we have a closing brace
|
|
205
|
+
const closingBraces: number[] = []
|
|
206
|
+
for (let i = 0; i < jsonStr.length; i++) {
|
|
207
|
+
if (jsonStr[i] === "}") {
|
|
208
|
+
closingBraces.push(i)
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
if (closingBraces.length === 0) {
|
|
213
|
+
return { success: false, originalError }
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// Binary search for the rightmost valid truncation point
|
|
217
|
+
let lo = 0
|
|
218
|
+
let hi = closingBraces.length - 1
|
|
219
|
+
let lastValid: { pos: number; entries: number } | null = null
|
|
220
|
+
|
|
221
|
+
while (lo <= hi) {
|
|
222
|
+
const mid = Math.floor((lo + hi) / 2)
|
|
223
|
+
const pos = closingBraces[mid]
|
|
224
|
+
if (pos === undefined) {
|
|
225
|
+
hi = mid - 1
|
|
226
|
+
continue
|
|
227
|
+
}
|
|
228
|
+
const testStr = `${jsonStr.substring(0, pos + 1)}]}}`
|
|
229
|
+
|
|
230
|
+
try {
|
|
231
|
+
const parsed = JSON.parse(testStr)
|
|
232
|
+
lastValid = { pos, entries: parsed.log?.entries?.length ?? 0 }
|
|
233
|
+
lo = mid + 1 // Try to find a later valid point
|
|
234
|
+
} catch {
|
|
235
|
+
hi = mid - 1 // This point is invalid, try earlier
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
if (lastValid) {
|
|
240
|
+
return {
|
|
241
|
+
success: true,
|
|
242
|
+
repairedJson: `${jsonStr.substring(0, lastValid.pos + 1)}]}}`,
|
|
243
|
+
entriesRecovered: lastValid.entries,
|
|
244
|
+
originalError,
|
|
245
|
+
repairMethod: "binary-search",
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
return { success: false, originalError }
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Extract a more descriptive error message from JSON parse errors.
|
|
254
|
+
*/
|
|
255
|
+
export function getDetailedJsonError(error: unknown, fileContent: string): string {
|
|
256
|
+
const err = error as Error
|
|
257
|
+
const message = err.message || "Unknown error"
|
|
258
|
+
|
|
259
|
+
// Try to extract position information
|
|
260
|
+
const posMatch = message.match(/position (\d+)/i)
|
|
261
|
+
if (posMatch?.[1]) {
|
|
262
|
+
const pos = Number.parseInt(posMatch[1], 10)
|
|
263
|
+
const lines = fileContent.substring(0, pos).split("\n")
|
|
264
|
+
const line = lines.length
|
|
265
|
+
const lastLine = lines[lines.length - 1]
|
|
266
|
+
const column = lastLine ? lastLine.length : 0
|
|
267
|
+
|
|
268
|
+
// Get context around the error
|
|
269
|
+
const start = Math.max(0, pos - 50)
|
|
270
|
+
const end = Math.min(fileContent.length, pos + 50)
|
|
271
|
+
const context = fileContent.substring(start, end)
|
|
272
|
+
|
|
273
|
+
return `JSON parse error at line ${line}, column ${column}: ${message}\nContext: ...${context}...`
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
return `JSON parse error: ${message}`
|
|
277
|
+
}
|