@gpzhang2001/sharpkit-proxy 0.2.1
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/LICENSE +201 -0
- package/README.md +27 -0
- package/THIRD_PARTY_NOTICES.md +48 -0
- package/lib/index.d.ts +110 -0
- package/lib/index.d.ts.map +1 -0
- package/lib/index.js +1307 -0
- package/lib/index.js.map +1 -0
- package/package.json +47 -0
- package/src/client.ts +436 -0
- package/src/index.ts +598 -0
- package/src/replay.ts +228 -0
package/src/replay.ts
ADDED
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure HTTP replay helpers, ported from strix tools/proxy/caido_api.py:
|
|
3
|
+
* raw request parsing/rebuilding with framing-header hygiene (:173-218),
|
|
4
|
+
* response parsing (:220-258), request-line parsing (:260-279), URL
|
|
5
|
+
* composition (:281-292), and field modifications (:294-331). Total pure
|
|
6
|
+
* functions — exhaustively unit-testable.
|
|
7
|
+
* @module @gpzhang2001/sharpkit-proxy/replay
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/** Parsed components of a raw HTTP request text. */
|
|
11
|
+
export interface RawRequestComponents {
|
|
12
|
+
readonly method: string
|
|
13
|
+
readonly urlPath: string
|
|
14
|
+
readonly headers: Record<string, string>
|
|
15
|
+
readonly body: string
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** A connection target for Caido's replay dispatch. */
|
|
19
|
+
export interface ConnectionInfo {
|
|
20
|
+
readonly host: string
|
|
21
|
+
readonly port: number
|
|
22
|
+
readonly tls: boolean
|
|
23
|
+
readonly sni?: string
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Parsed summary of a raw HTTP response (strix list_requests shape). */
|
|
27
|
+
export interface RawResponseParts {
|
|
28
|
+
readonly statusCode: number
|
|
29
|
+
readonly length: number
|
|
30
|
+
readonly headers: Record<string, string>
|
|
31
|
+
readonly body: string
|
|
32
|
+
readonly bodyTruncated: boolean
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** strix clips replay response bodies at 8192 chars (caido_api.py:222). */
|
|
36
|
+
const RESPONSE_BODY_MAX_CHARS = 8192
|
|
37
|
+
|
|
38
|
+
/** Framing headers that must never survive into a modified replay (strix :175). */
|
|
39
|
+
const FRAMING_HEADERS = new Set(['content-length', 'transfer-encoding'])
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Parse a raw HTTP request text into components (strix `parse_raw_request`).
|
|
43
|
+
* @param rawContent - the raw request text.
|
|
44
|
+
* @returns the parsed components.
|
|
45
|
+
* @throws when the request line is malformed.
|
|
46
|
+
*/
|
|
47
|
+
export function parseRawRequest(rawContent: string): RawRequestComponents {
|
|
48
|
+
const lines = rawContent.split('\n')
|
|
49
|
+
const requestLine = (lines[0] ?? '').trim().split(' ')
|
|
50
|
+
if (requestLine.length < 2) throw new Error('Invalid request line format')
|
|
51
|
+
const headers: Record<string, string> = {}
|
|
52
|
+
let bodyStart = 0
|
|
53
|
+
for (let index = 1; index < lines.length; index++) {
|
|
54
|
+
const line = lines[index] ?? ''
|
|
55
|
+
if (line.trim() === '') {
|
|
56
|
+
bodyStart = index + 1
|
|
57
|
+
break
|
|
58
|
+
}
|
|
59
|
+
const separator = line.indexOf(':')
|
|
60
|
+
if (separator !== -1) headers[line.slice(0, separator).trim()] = line.slice(separator + 1).trim()
|
|
61
|
+
}
|
|
62
|
+
const body = bodyStart < lines.length ? lines.slice(bodyStart).join('\n').trim() : ''
|
|
63
|
+
return { method: requestLine[0] ?? '', urlPath: requestLine[1] ?? '', headers, body }
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Compose the full URL from the original request's connection facts, the
|
|
68
|
+
* parsed components, and an optional explicit url override (strix
|
|
69
|
+
* `full_url_from_components`).
|
|
70
|
+
* @param original - the stored request's host/tls facts.
|
|
71
|
+
* @param components - parsed raw request components.
|
|
72
|
+
* @param modifications - the patch dict.
|
|
73
|
+
* @returns the absolute target URL.
|
|
74
|
+
*/
|
|
75
|
+
export function fullUrlFromComponents(
|
|
76
|
+
original: { readonly host: string; readonly tls: boolean },
|
|
77
|
+
components: RawRequestComponents,
|
|
78
|
+
modifications: Readonly<Record<string, unknown>>,
|
|
79
|
+
): string {
|
|
80
|
+
const override = modifications['url']
|
|
81
|
+
if (typeof override === 'string' && override !== '') return override
|
|
82
|
+
const hostHeader = components.headers['Host'] ?? original.host
|
|
83
|
+
const scheme = original.tls ? 'https' : 'http'
|
|
84
|
+
return `${scheme}://${hostHeader}${components.urlPath}`
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Parse a query string into a first-value map (Node parity of parse_qs). */
|
|
88
|
+
function parseQuery(query: string): Record<string, string> {
|
|
89
|
+
const params: Record<string, string> = {}
|
|
90
|
+
for (const pair of query.split('&')) {
|
|
91
|
+
if (pair === '') continue
|
|
92
|
+
const eq = pair.indexOf('=')
|
|
93
|
+
const key = eq === -1 ? pair : pair.slice(0, eq)
|
|
94
|
+
const value = eq === -1 ? '' : pair.slice(eq + 1)
|
|
95
|
+
params[decodeURIComponent(key)] = decodeURIComponent(value)
|
|
96
|
+
}
|
|
97
|
+
return params
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Serialize a query map back to a string (Node parity of urlencode). */
|
|
101
|
+
function encodeQuery(params: Readonly<Record<string, string>>): string {
|
|
102
|
+
return Object.entries(params)
|
|
103
|
+
.map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(value)}`)
|
|
104
|
+
.join('&')
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Overlay the patch dict onto parsed components (strix `apply_modifications`):
|
|
109
|
+
* params merge into the query string, headers/cookies merge, body replaces.
|
|
110
|
+
* @param components - parsed raw request components.
|
|
111
|
+
* @param modifications - the patch dict (url/params/headers/body/cookies).
|
|
112
|
+
* @param fullUrl - the composed final URL.
|
|
113
|
+
* @returns the modified components keyed like strix.
|
|
114
|
+
*/
|
|
115
|
+
export function applyModifications(
|
|
116
|
+
components: RawRequestComponents,
|
|
117
|
+
modifications: Readonly<Record<string, unknown>>,
|
|
118
|
+
fullUrl: string,
|
|
119
|
+
): { readonly method: string; readonly url: string; readonly headers: Record<string, string>; readonly body: string } {
|
|
120
|
+
const headers = { ...components.headers }
|
|
121
|
+
let body = components.body
|
|
122
|
+
let finalUrl = fullUrl
|
|
123
|
+
|
|
124
|
+
const params = modifications['params']
|
|
125
|
+
if (params !== undefined && params !== null && typeof params === 'object') {
|
|
126
|
+
const question = finalUrl.indexOf('?')
|
|
127
|
+
const existing = question === -1 ? {} : parseQuery(finalUrl.slice(question + 1))
|
|
128
|
+
for (const [key, value] of Object.entries(params as Record<string, unknown>)) {
|
|
129
|
+
existing[key] = String(value)
|
|
130
|
+
}
|
|
131
|
+
const base = question === -1 ? finalUrl : finalUrl.slice(0, question)
|
|
132
|
+
finalUrl = `${base}?${encodeQuery(existing)}`
|
|
133
|
+
}
|
|
134
|
+
const headerPatch = modifications['headers']
|
|
135
|
+
if (headerPatch !== undefined && headerPatch !== null && typeof headerPatch === 'object') {
|
|
136
|
+
for (const [key, value] of Object.entries(headerPatch as Record<string, unknown>)) {
|
|
137
|
+
headers[key] = String(value)
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
const bodyPatch = modifications['body']
|
|
141
|
+
if (typeof bodyPatch === 'string') body = bodyPatch
|
|
142
|
+
const cookiePatch = modifications['cookies']
|
|
143
|
+
if (cookiePatch !== undefined && cookiePatch !== null && typeof cookiePatch === 'object') {
|
|
144
|
+
const cookies: Record<string, string> = {}
|
|
145
|
+
const existingCookie = headers['Cookie']
|
|
146
|
+
if (existingCookie !== undefined) {
|
|
147
|
+
for (const cookie of existingCookie.split(';')) {
|
|
148
|
+
const eq = cookie.indexOf('=')
|
|
149
|
+
if (eq !== -1) cookies[cookie.slice(0, eq).trim()] = cookie.slice(eq + 1).trim()
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
for (const [key, value] of Object.entries(cookiePatch as Record<string, unknown>)) {
|
|
153
|
+
cookies[key] = String(value)
|
|
154
|
+
}
|
|
155
|
+
headers['Cookie'] = Object.entries(cookies).map(([key, value]) => `${key}=${value}`).join('; ')
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
return { method: components.method, url: finalUrl, headers, body }
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Rebuild a raw HTTP/1.1 request with connection facts (strix
|
|
163
|
+
* `build_raw_request`): Host/UA defaults, framing headers dropped,
|
|
164
|
+
* Content-Length recomputed from the actual body.
|
|
165
|
+
* @param parts - method/url/headers/body of the replay.
|
|
166
|
+
* @returns the connection target and encoded raw request bytes.
|
|
167
|
+
*/
|
|
168
|
+
export function buildRawRequest(parts: {
|
|
169
|
+
readonly method: string
|
|
170
|
+
readonly url: string
|
|
171
|
+
readonly headers: Record<string, string>
|
|
172
|
+
readonly body: string
|
|
173
|
+
}): { readonly connection: ConnectionInfo; readonly raw: Uint8Array } {
|
|
174
|
+
let parsed: URL
|
|
175
|
+
try {
|
|
176
|
+
parsed = new URL(parts.url)
|
|
177
|
+
} catch {
|
|
178
|
+
throw new Error(`Invalid URL: ${parts.url}`)
|
|
179
|
+
}
|
|
180
|
+
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') throw new Error(`Invalid URL: ${parts.url}`)
|
|
181
|
+
const tls = parsed.protocol === 'https:'
|
|
182
|
+
const port = parsed.port !== '' ? Number(parsed.port) : tls ? 443 : 80
|
|
183
|
+
const path = `${parsed.pathname}${parsed.search}`
|
|
184
|
+
|
|
185
|
+
const headers: Record<string, string> = { ...parts.headers }
|
|
186
|
+
if (headers['Host'] === undefined) headers['Host'] = parsed.host
|
|
187
|
+
if (headers['User-Agent'] === undefined) {
|
|
188
|
+
headers['User-Agent'] = 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36'
|
|
189
|
+
}
|
|
190
|
+
for (const key of Object.keys(headers)) {
|
|
191
|
+
if (FRAMING_HEADERS.has(key.toLowerCase())) delete headers[key]
|
|
192
|
+
}
|
|
193
|
+
if (parts.body !== '') headers['Content-Length'] = String(Buffer.byteLength(parts.body, 'utf8'))
|
|
194
|
+
|
|
195
|
+
const lines = [`${parts.method.toUpperCase()} ${path} HTTP/1.1`]
|
|
196
|
+
for (const [key, value] of Object.entries(headers)) lines.push(`${key}: ${value}`)
|
|
197
|
+
const raw = Buffer.from(`${lines.join('\r\n')}\r\n\r\n${parts.body}`, 'utf8')
|
|
198
|
+
return { connection: { host: parsed.hostname, port, tls }, raw }
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Parse a raw HTTP response into the list_requests summary shape (strix
|
|
203
|
+
* `parse_raw_response`); null when missing/unparseable. Body clipped at 8192
|
|
204
|
+
* chars with a truncation flag.
|
|
205
|
+
* @param rawBytes - the raw response bytes.
|
|
206
|
+
* @returns the parsed parts, or null.
|
|
207
|
+
*/
|
|
208
|
+
export function parseRawResponse(rawBytes: Uint8Array | null | undefined): RawResponseParts | null {
|
|
209
|
+
if (rawBytes === null || rawBytes === undefined || rawBytes.byteLength === 0) return null
|
|
210
|
+
const text = Buffer.from(rawBytes).toString('latin1')
|
|
211
|
+
const separator = text.indexOf('\r\n\r\n')
|
|
212
|
+
if (separator === -1) return null
|
|
213
|
+
const head = text.slice(0, separator)
|
|
214
|
+
const lines = head.split('\r\n')
|
|
215
|
+
const statusParts = (lines[0] ?? '').split(' ')
|
|
216
|
+
if (statusParts.length < 2 || !/^\d+$/.test(statusParts[1] ?? '')) return null
|
|
217
|
+
const headers: Record<string, string> = {}
|
|
218
|
+
for (const line of lines.slice(1)) {
|
|
219
|
+
const colon = line.indexOf(':')
|
|
220
|
+
if (colon === -1) continue
|
|
221
|
+
headers[line.slice(0, colon).trim()] = line.slice(colon + 1).trim()
|
|
222
|
+
}
|
|
223
|
+
const bodyBytes = Buffer.from(text.slice(separator + 4), 'latin1')
|
|
224
|
+
let body = bodyBytes.toString('utf8')
|
|
225
|
+
const truncated = body.length > RESPONSE_BODY_MAX_CHARS
|
|
226
|
+
if (truncated) body = body.slice(0, RESPONSE_BODY_MAX_CHARS)
|
|
227
|
+
return { statusCode: Number(statusParts[1]), length: bodyBytes.byteLength, headers, body, bodyTruncated: truncated }
|
|
228
|
+
}
|