@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/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
+ }