@lievclouds/dsh-cortex 0.4.4 → 1.0.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.

Potentially problematic release.


This version of @lievclouds/dsh-cortex might be problematic. Click here for more details.

Files changed (58) hide show
  1. package/README.md +1 -1
  2. package/client.js +428 -406
  3. package/index.js +7 -0
  4. package/locale/en.json +6 -6
  5. package/locale/zh.json +6 -6
  6. package/package.json +115 -106
  7. package/src/cortex-ui/dsh/api.ts +5 -2
  8. package/src/cortex-ui/dsh/css-overrides.css +8 -0
  9. package/src/cortex-ui/renderer/App.tsx +39 -0
  10. package/src/cortex-ui/renderer/components/asset/AssetDetail.tsx +9 -1
  11. package/src/cortex-ui/renderer/components/miniapp/FileTree.tsx +161 -0
  12. package/src/cortex-ui/renderer/components/miniapp/MiniappCard.tsx +263 -0
  13. package/src/cortex-ui/renderer/components/miniapp/MiniappDetail.tsx +1493 -0
  14. package/src/cortex-ui/renderer/components/retest/CurlImportDialog.tsx +158 -0
  15. package/src/cortex-ui/renderer/components/retest/RepeaterCollectionsDialog.tsx +327 -0
  16. package/src/cortex-ui/renderer/components/retest/RepeaterHistoryDialog.tsx +242 -0
  17. package/src/cortex-ui/renderer/components/retest/RepeaterPanel.tsx +333 -148
  18. package/src/cortex-ui/renderer/components/retest/YakitSendPanel.tsx +411 -0
  19. package/src/cortex-ui/renderer/components/vuln/DirectoryPickerDialog.tsx +187 -0
  20. package/src/cortex-ui/renderer/components/vuln/ExportReportDialog.tsx +173 -59
  21. package/src/cortex-ui/renderer/components/vuln/VulnDetail.tsx +16 -4
  22. package/src/cortex-ui/renderer/i18n/messages/common.ts +2 -0
  23. package/src/cortex-ui/renderer/i18n/messages/fuzzer.ts +32 -0
  24. package/src/cortex-ui/renderer/i18n/messages/index.ts +5 -1
  25. package/src/cortex-ui/renderer/i18n/messages/miniapp.ts +392 -0
  26. package/src/cortex-ui/renderer/i18n/messages/retest.ts +146 -2
  27. package/src/cortex-ui/renderer/i18n/messages/vuln.ts +26 -6
  28. package/src/cortex-ui/renderer/lib/curl.ts +635 -0
  29. package/src/cortex-ui/renderer/lib/miniappApi.ts +459 -0
  30. package/src/cortex-ui/renderer/lib/navigation.ts +13 -1
  31. package/src/cortex-ui/renderer/lib/repeaterRaw.ts +102 -0
  32. package/src/cortex-ui/renderer/lib/repeaterStoreApi.ts +163 -0
  33. package/src/cortex-ui/renderer/lib/retestResponse.ts +64 -0
  34. package/src/cortex-ui/renderer/lib/transport/httpClient.ts +1 -1
  35. package/src/cortex-ui/renderer/pages/DashboardPage.tsx +3 -3
  36. package/src/cortex-ui/renderer/pages/FuzzerPage.tsx +212 -0
  37. package/src/cortex-ui/renderer/pages/MiniappPage.tsx +664 -0
  38. package/src/cortex-ui/shared/ipc.ts +3 -4
  39. package/src/cortex-ui/shared/types.ts +9 -0
  40. package/src/host/cortex/fs.js +72 -0
  41. package/src/host/cortex/index.js +49 -1
  42. package/src/host/cortex/repeater-store.js +411 -0
  43. package/src/host/cortex/repeater.js +15 -2
  44. package/src/host/cortex/settings.js +54 -0
  45. package/src/host/cortex/tools.js +2 -0
  46. package/src/host/cortex/vulns.js +36 -13
  47. package/src/host/miniapp/analyze.js +1078 -0
  48. package/src/host/miniapp/assets-import.js +294 -0
  49. package/src/host/miniapp/detect.js +426 -0
  50. package/src/host/miniapp/format.js +263 -0
  51. package/src/host/miniapp/roots.js +360 -0
  52. package/src/host/miniapp/routes.js +304 -0
  53. package/src/host/miniapp/service.js +559 -0
  54. package/src/host/miniapp/store.js +756 -0
  55. package/src/host/miniapp/tools.js +335 -0
  56. package/src/host/miniapp/unpack.js +1010 -0
  57. package/src/host/miniapp/wxapkg.js +416 -0
  58. package/src/host/reports-office.js +341 -0
@@ -0,0 +1,459 @@
1
+ /**
2
+ * Typed client for the mini-program reverse-engineering routes.
3
+ *
4
+ * Contract: `.tmp/miniapp-contract.md` §3.7 — every route lives under
5
+ * `/cortex-api/cortex`, talks JSON and uses the host `{error, code}` envelope
6
+ * for non-2xx responses. The page calls these wrappers directly (instead of
7
+ * `CortexApi`) because the miniapp feature is plugin-host-only and does not
8
+ * exist in the upstream Cortex IPC surface.
9
+ */
10
+
11
+ const BASE = '/cortex-api/cortex'
12
+
13
+ export class MiniappApiError extends Error {
14
+ code: string
15
+
16
+ constructor(message: string, code: string) {
17
+ super(message)
18
+ this.name = 'MiniappApiError'
19
+ this.code = code
20
+ }
21
+ }
22
+
23
+ export type MiniappStatus = 'detected' | 'decrypting' | 'unpacked' | 'unsupported' | 'failed'
24
+
25
+ export const MINIAPP_STATUSES: readonly MiniappStatus[] = [
26
+ 'detected',
27
+ 'decrypting',
28
+ 'unpacked',
29
+ 'unsupported',
30
+ 'failed'
31
+ ]
32
+
33
+ export type MiniappFormat = 'plain' | 'v1mmwx' | 'unknown'
34
+
35
+ export type MiniappKind = 'app' | 'plugin' | 'library' | 'unknown'
36
+
37
+ /** One detected package group (all packages of one appid+version). */
38
+ export interface MiniappRow {
39
+ id: string
40
+ appid: string
41
+ app_name: string
42
+ kind?: MiniappKind
43
+ version: string
44
+ root_kind: string
45
+ package_dir: string
46
+ size_bytes: number
47
+ file_count: number
48
+ format: MiniappFormat | string
49
+ status: MiniappStatus
50
+ unpacked_path: string
51
+ error: string
52
+ detected_at: string
53
+ updated_at: string
54
+ version_count?: number
55
+ is_latest?: boolean
56
+ has_analysis?: boolean
57
+ /** Number of WeChat user-cache copies collapsed into this row. */
58
+ source_count?: number
59
+ }
60
+
61
+ export interface MiniappListResponse {
62
+ miniapps: MiniappRow[]
63
+ total: number
64
+ appCount: number
65
+ customRoots: string[]
66
+ workspaceRoot: string
67
+ lastScanAt: string | null
68
+ }
69
+
70
+ export interface MiniappScanError {
71
+ root?: string
72
+ path?: string
73
+ message: string
74
+ }
75
+
76
+ export interface MiniappScanRoot {
77
+ kind: string
78
+ path: string
79
+ exists: boolean
80
+ filesFound?: number
81
+ errors?: number
82
+ }
83
+
84
+ export interface MiniappScanResponse {
85
+ ok: boolean
86
+ scannedAt: string
87
+ totalSources: number
88
+ totalPackages: number
89
+ added: number
90
+ updated: number
91
+ unchanged: number
92
+ errors: MiniappScanError[]
93
+ roots?: MiniappScanRoot[]
94
+ appCount?: number
95
+ miniapps: MiniappRow[]
96
+ }
97
+
98
+ export interface MiniappRoot {
99
+ kind: string
100
+ path: string
101
+ label: string
102
+ exists: boolean
103
+ }
104
+
105
+ export interface MiniappRootsResponse {
106
+ roots: MiniappRoot[]
107
+ customRoots: string[]
108
+ }
109
+
110
+ export interface MiniappVersion {
111
+ id: string
112
+ version: string
113
+ status: MiniappStatus
114
+ format: string
115
+ size_bytes: number
116
+ file_count: number
117
+ updated_at: string
118
+ unpacked_path: string
119
+ source_count?: number
120
+ }
121
+
122
+ export interface MiniappManifestTotals {
123
+ packages?: number
124
+ files?: number
125
+ bytes?: number
126
+ skipped?: number
127
+ conflicts?: number
128
+ unsupportedPackages?: number
129
+ }
130
+
131
+ export interface MiniappManifest {
132
+ appid?: string
133
+ version?: string
134
+ generatedAt?: string
135
+ packages?: Array<{ file: string; size: number; format: string; entries: number; bytes: number }>
136
+ totals?: MiniappManifestTotals
137
+ files?: Array<{ path: string; size: number }>
138
+ conflicts?: Array<{ path: string; keptFrom: string; ignoredFrom: string }>
139
+ error?: string
140
+ }
141
+
142
+ export interface MiniappFileStats {
143
+ total: number
144
+ text: number
145
+ binary: number
146
+ bytes: number
147
+ }
148
+
149
+ export interface MiniappAnalysis {
150
+ appid: string
151
+ version: string
152
+ generatedAt: string
153
+ files: MiniappFileStats
154
+ byExtension: Array<{ ext: string; count: number; bytes: number }>
155
+ pages: string[]
156
+ subPackages: Array<{ root: string; pages: string[] }>
157
+ domains: Array<{ host: string; count: number; examples: string[]; files: string[] }>
158
+ endpoints: Array<{ path: string; method?: string; count: number; files: string[] }>
159
+ secrets: Array<{
160
+ info_type: string
161
+ name: string
162
+ value_masked: string
163
+ value?: string
164
+ source: string
165
+ line: number
166
+ potential_impact?: string
167
+ }>
168
+ parameters: Array<{
169
+ name: string
170
+ endpoint: string
171
+ location: string
172
+ values: string[]
173
+ pattern?: string
174
+ source: string
175
+ }>
176
+ largestFiles: Array<{ path: string; size: number }>
177
+ warnings: string[]
178
+ }
179
+
180
+ export interface MiniappExtractedCounts {
181
+ domains: number
182
+ endpoints: number
183
+ secrets: number
184
+ parameters: number
185
+ }
186
+
187
+ export interface MiniappDetailResponse {
188
+ miniapp: MiniappRow & { versions: MiniappVersion[] }
189
+ manifest: MiniappManifest | null
190
+ analysis: MiniappAnalysis | null
191
+ overviewText: string
192
+ extractedCounts: MiniappExtractedCounts
193
+ }
194
+
195
+ export interface MiniappUnpackTotals {
196
+ packages?: number
197
+ files?: number
198
+ bytes?: number
199
+ skipped?: number
200
+ conflicts?: number
201
+ }
202
+
203
+ export interface MiniappUnpackResponse {
204
+ ok: boolean
205
+ status: MiniappStatus
206
+ miniapp: MiniappRow
207
+ manifest: MiniappManifest | null
208
+ totals: MiniappUnpackTotals
209
+ error: string
210
+ }
211
+
212
+ export interface MiniappAnalyzeResponse {
213
+ ok: boolean
214
+ analysis: MiniappAnalysis | null
215
+ overviewText: string
216
+ counts: MiniappExtractedCounts
217
+ }
218
+
219
+ export interface MiniappTreeEntry {
220
+ name: string
221
+ path: string
222
+ type: 'file' | 'dir'
223
+ size: number
224
+ }
225
+
226
+ export interface MiniappTreeResponse {
227
+ path: string
228
+ entries: MiniappTreeEntry[]
229
+ }
230
+
231
+ export interface MiniappFileMatch {
232
+ line: number
233
+ text: string
234
+ }
235
+
236
+ export interface MiniappFileResponse {
237
+ path: string
238
+ content: string
239
+ offset: number
240
+ limit: number
241
+ loadedLines?: number
242
+ totalLines: number
243
+ hasMore?: boolean
244
+ truncated: boolean
245
+ sourceTruncated?: boolean
246
+ matches?: MiniappFileMatch[]
247
+ matchCount?: number
248
+ matchTruncated?: boolean
249
+ size: number
250
+ binary: boolean
251
+ formatted?: boolean
252
+ language?: string
253
+ }
254
+
255
+ export interface MiniappSearchMatch {
256
+ file: string
257
+ line: number
258
+ text: string
259
+ }
260
+
261
+ export interface MiniappSearchResponse {
262
+ matches: MiniappSearchMatch[]
263
+ scanned: number
264
+ truncated: boolean
265
+ query: string
266
+ }
267
+
268
+ export interface MiniappImportAsset {
269
+ host_port: string
270
+ created: boolean
271
+ interfaces: number
272
+ leaks: number
273
+ parameters: number
274
+ }
275
+
276
+ export interface MiniappImportResponse {
277
+ ok: boolean
278
+ project: string
279
+ assets: MiniappImportAsset[]
280
+ totals: { assets: number; interfaces: number; leaks: number; parameters: number }
281
+ warnings: string[]
282
+ }
283
+
284
+ export interface MiniappOpenResponse {
285
+ ok: boolean
286
+ path: string
287
+ }
288
+
289
+ export interface MiniappListParams {
290
+ search?: string
291
+ status?: MiniappStatus | ''
292
+ }
293
+
294
+ function toQuery(query: Record<string, unknown> | undefined): string {
295
+ if (!query) return ''
296
+ const params = new URLSearchParams()
297
+ for (const key of Object.keys(query)) {
298
+ const value = query[key]
299
+ if (value === undefined || value === null || value === '') continue
300
+ params.set(key, String(value))
301
+ }
302
+ const text = params.toString()
303
+ return text ? `?${text}` : ''
304
+ }
305
+
306
+ async function request<T>(path: string, init?: { method?: string; json?: unknown }): Promise<T> {
307
+ const method = (init && init.method) || 'GET'
308
+ const options: RequestInit = {
309
+ method,
310
+ credentials: 'same-origin',
311
+ cache: 'no-store',
312
+ headers: { Accept: 'application/json' }
313
+ }
314
+ if (init && init.json !== undefined) {
315
+ options.body = JSON.stringify(init.json)
316
+ ;(options.headers as Record<string, string>)['Content-Type'] = 'application/json'
317
+ }
318
+ const response = await fetch(BASE + path, options)
319
+ if (!response.ok) {
320
+ let payload: {
321
+ error?: string
322
+ message?: string
323
+ code?: string
324
+ details?: { code?: string }
325
+ } | null = null
326
+ try {
327
+ payload = await response.json()
328
+ } catch {
329
+ /* non-JSON error */
330
+ }
331
+ // The host envelope uses a generic `BAD_REQUEST` code and puts the specific
332
+ // reason (e.g. `BINARY_FILE`) in `details.code`; prefer the specific one so
333
+ // callers can branch on it without parsing messages.
334
+ const specific = payload?.details?.code
335
+ throw new MiniappApiError(
336
+ String(payload?.error || payload?.message || `HTTP ${response.status}`),
337
+ String(specific || payload?.code || `HTTP_${response.status}`)
338
+ )
339
+ }
340
+ if (response.status === 204) return undefined as T
341
+ const text = await response.text()
342
+ if (!text) return undefined as T
343
+ return JSON.parse(text) as T
344
+ }
345
+
346
+ const encodeId = (id: string): string => encodeURIComponent(id)
347
+
348
+ export function listMiniapps(params: MiniappListParams = {}): Promise<MiniappListResponse> {
349
+ return request<MiniappListResponse>(`/miniapps${toQuery({ search: params.search, status: params.status })}`)
350
+ }
351
+
352
+ export function scanMiniapps(customRoots: string[] = []): Promise<MiniappScanResponse> {
353
+ return request<MiniappScanResponse>('/miniapps/scan', { method: 'POST', json: { customRoots } })
354
+ }
355
+
356
+ export function listMiniappRoots(): Promise<MiniappRootsResponse> {
357
+ return request<MiniappRootsResponse>('/miniapps/roots')
358
+ }
359
+
360
+ export function setMiniappRoots(
361
+ roots: string[],
362
+ mode: 'replace' | 'append' = 'append'
363
+ ): Promise<MiniappRootsResponse> {
364
+ return request<MiniappRootsResponse>('/miniapps/roots', { method: 'POST', json: { roots, mode } })
365
+ }
366
+
367
+ export function getMiniapp(id: string): Promise<MiniappDetailResponse> {
368
+ return request<MiniappDetailResponse>(`/miniapps/${encodeId(id)}`)
369
+ }
370
+
371
+ export function unpackMiniapp(id: string): Promise<MiniappUnpackResponse> {
372
+ return request<MiniappUnpackResponse>(`/miniapps/${encodeId(id)}/unpack`, {
373
+ method: 'POST',
374
+ json: {}
375
+ })
376
+ }
377
+
378
+ export function analyzeMiniapp(id: string): Promise<MiniappAnalyzeResponse> {
379
+ return request<MiniappAnalyzeResponse>(`/miniapps/${encodeId(id)}/analyze`, {
380
+ method: 'POST',
381
+ json: {}
382
+ })
383
+ }
384
+
385
+ export function getMiniappTree(id: string, path = ''): Promise<MiniappTreeResponse> {
386
+ return request<MiniappTreeResponse>(`/miniapps/${encodeId(id)}/tree${toQuery({ path })}`)
387
+ }
388
+
389
+ export function readMiniappFile(
390
+ id: string,
391
+ path: string,
392
+ offset = 0,
393
+ limit = 2000,
394
+ maxBytes = 1024 * 1024,
395
+ find = '',
396
+ findRegex = false
397
+ ): Promise<MiniappFileResponse> {
398
+ return request<MiniappFileResponse>(
399
+ `/miniapps/${encodeId(id)}/file${toQuery({
400
+ path,
401
+ offset,
402
+ limit,
403
+ maxBytes,
404
+ find,
405
+ findRegex: findRegex ? 1 : 0
406
+ })}`
407
+ )
408
+ }
409
+
410
+ export interface MiniappSearchParams {
411
+ query: string
412
+ regex?: boolean
413
+ glob?: string
414
+ maxResults?: number
415
+ }
416
+
417
+ export function searchMiniappFiles(
418
+ id: string,
419
+ params: MiniappSearchParams
420
+ ): Promise<MiniappSearchResponse> {
421
+ return request<MiniappSearchResponse>(
422
+ `/miniapps/${encodeId(id)}/search${toQuery({
423
+ query: params.query,
424
+ regex: params.regex ? 1 : 0,
425
+ glob: params.glob,
426
+ maxResults: params.maxResults
427
+ })}`
428
+ )
429
+ }
430
+
431
+ export function importMiniappAssets(
432
+ id: string,
433
+ input: { project_name?: string; host_port?: string } = {}
434
+ ): Promise<MiniappImportResponse> {
435
+ return request<MiniappImportResponse>(`/miniapps/${encodeId(id)}/import-assets`, {
436
+ method: 'POST',
437
+ json: input
438
+ })
439
+ }
440
+
441
+ export function openMiniappDir(id: string): Promise<MiniappOpenResponse> {
442
+ return request<MiniappOpenResponse>(`/miniapps/${encodeId(id)}/open`, {
443
+ method: 'POST',
444
+ json: {}
445
+ })
446
+ }
447
+
448
+ export function renameMiniapp(id: string, appName: string): Promise<{ ok: boolean; miniapp: MiniappRow }> {
449
+ return request<{ ok: boolean; miniapp: MiniappRow }>(`/miniapps/${encodeId(id)}/name`, {
450
+ method: 'POST',
451
+ json: { app_name: appName }
452
+ })
453
+ }
454
+
455
+ export function toMiniappErrorMessage(error: unknown): string {
456
+ if (error instanceof Error) return error.message
457
+ if (typeof error === 'string') return error
458
+ return String(error)
459
+ }
@@ -1,4 +1,4 @@
1
- import { Bot, Boxes, Brain, FlaskConical, Network, Orbit, ShieldAlert, type LucideIcon } from 'lucide-react'
1
+ import { Bot, Boxes, Brain, FlaskConical, Network, Orbit, PackageSearch, ShieldAlert, Zap, type LucideIcon } from 'lucide-react'
2
2
 
3
3
  export type AppPage =
4
4
  | 'dashboard'
@@ -8,6 +8,8 @@ export type AppPage =
8
8
  | 'snapshots'
9
9
  | 'mcp'
10
10
  | 'retest'
11
+ | 'fuzzer'
12
+ | 'miniapps'
11
13
  | 'ai-config'
12
14
 
13
15
  /** Cross-page deep link used by the nebula inspector ("view full details"). */
@@ -37,6 +39,8 @@ export const NAV_ITEMS: readonly NavItem[] = [
37
39
  { page: 'assets', labelKey: 'nav.assets', icon: Boxes },
38
40
  { page: 'snapshots', labelKey: 'nav.snapshots', icon: Brain },
39
41
  { page: 'retest', labelKey: 'nav.retest', icon: FlaskConical },
42
+ { page: 'fuzzer', labelKey: 'nav.fuzzer', icon: Zap },
43
+ { page: 'miniapps', labelKey: 'nav.miniapps', icon: PackageSearch },
40
44
  { page: 'ai-config', labelKey: 'retest.aiConfig.nav', icon: Bot }
41
45
  ]
42
46
 
@@ -65,6 +69,14 @@ export const PAGE_META: Record<AppPage, { titleKey: string; descriptionKey: stri
65
69
  titleKey: 'nav.retest',
66
70
  descriptionKey: 'retest.page.repeaterSubtitle'
67
71
  },
72
+ fuzzer: {
73
+ titleKey: 'nav.fuzzer',
74
+ descriptionKey: 'fuzzer.page.description'
75
+ },
76
+ miniapps: {
77
+ titleKey: 'nav.miniapps',
78
+ descriptionKey: 'miniapp.page.description'
79
+ },
68
80
  'ai-config': {
69
81
  titleKey: 'retest.aiConfig.nav',
70
82
  descriptionKey: 'retest.aiConfig.description'
@@ -0,0 +1,102 @@
1
+ /**
2
+ * Raw HTTP request helpers shared by the single Repeater and the Fuzzer.
3
+ *
4
+ * The request editor uses the Burp-style raw form:
5
+ *
6
+ * POST /api/login HTTP/1.1
7
+ * Host: target
8
+ * Content-Type: application/json
9
+ *
10
+ * {"user":"demo"}
11
+ *
12
+ * Fuzzer injection points are marked inline with paired `§` characters, e.g.
13
+ * `id=§1§`. Nothing in this module talks to the network.
14
+ */
15
+
16
+ export interface ParsedRawRequest {
17
+ method: string
18
+ url: string
19
+ headers: Record<string, string>
20
+ body: string
21
+ }
22
+
23
+ /**
24
+ * Protocol-level headers copied from a report are re-derived by the outbound
25
+ * request (Host from the URL, length/connection by the HTTP client). Dropping
26
+ * them here keeps raw requests sendable without a validation error.
27
+ */
28
+ export const DERIVED_HEADERS = new Set([
29
+ 'host',
30
+ 'content-length',
31
+ 'connection',
32
+ 'transfer-encoding',
33
+ 'upgrade',
34
+ 'keep-alive',
35
+ 'proxy-connection'
36
+ ])
37
+
38
+ /** Parse a Burp-like raw request into the typed repeater payload. */
39
+ export function parseRawRequest(text: string): ParsedRawRequest {
40
+ const lines = text.replace(/\r\n/g, '\n').split('\n')
41
+ const requestLine = (lines.shift() ?? '').trim()
42
+ const match = /^([A-Za-z]+)\s+(\S+)\s+HTTP\/\d(?:\.\d)?$/.exec(requestLine)
43
+ if (match === null) throw new Error('请求行必须形如:GET /path HTTP/1.1')
44
+ const headers: Record<string, string> = {}
45
+ let index = 0
46
+ for (; index < lines.length; index += 1) {
47
+ const line = lines[index]
48
+ if (line === undefined) break
49
+ if (line.trim() === '') {
50
+ index += 1
51
+ break
52
+ }
53
+ const colon = line.indexOf(':')
54
+ if (colon <= 0) throw new Error(`请求头不合法:${line}`)
55
+ const name = line.slice(0, colon).trim()
56
+ const value = line.slice(colon + 1).trim()
57
+ if (name !== '') headers[name] = value
58
+ }
59
+ const body = lines.slice(index).join('\n')
60
+ return { method: match[1].toUpperCase(), url: match[2], headers, body }
61
+ }
62
+
63
+ /** Serialize the typed payload back into the raw editor form. */
64
+ export function serializeRawRequest(request: {
65
+ method: string
66
+ url: string
67
+ headers: Record<string, string>
68
+ body?: string
69
+ }): string {
70
+ const lines = [`${request.method.toUpperCase()} ${request.url} HTTP/1.1`]
71
+ for (const [name, value] of Object.entries(request.headers)) lines.push(`${name}: ${value}`)
72
+ const body = request.body ?? ''
73
+ if (body !== '') lines.push('', body)
74
+ return lines.join('\n')
75
+ }
76
+
77
+ /** Copy a header map without the protocol-level headers the Host re-derives. */
78
+ export function stripDerivedHeaders(headers: Record<string, string>): Record<string, string> {
79
+ return Object.fromEntries(
80
+ Object.entries(headers).filter(([name]) => !DERIVED_HEADERS.has(name.toLowerCase()))
81
+ )
82
+ }
83
+
84
+ /**
85
+ * Pretty-print a JSON request body while keeping the request line and headers
86
+ * untouched. Returns null when the request has no body or the body is not JSON.
87
+ */
88
+ export function formatRequestBody(raw: string): string | null {
89
+ const text = raw.replace(/\r\n/g, '\n')
90
+ const separator = text.indexOf('\n\n')
91
+ if (separator < 0) return null
92
+ const head = text.slice(0, separator)
93
+ const body = text.slice(separator + 2).trim()
94
+ if (body === '') return null
95
+ let parsed: unknown
96
+ try {
97
+ parsed = JSON.parse(body)
98
+ } catch {
99
+ return null
100
+ }
101
+ return `${head}\n\n${JSON.stringify(parsed, null, 2)}`
102
+ }