htpx-cli 0.1.2 → 0.2.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.
Files changed (252) hide show
  1. package/LICENSE +665 -21
  2. package/README.md +422 -116
  3. package/dist/cli/commands/clear.d.ts.map +1 -1
  4. package/dist/cli/commands/clear.js +11 -11
  5. package/dist/cli/commands/clear.js.map +1 -1
  6. package/dist/cli/commands/daemon.d.ts +3 -0
  7. package/dist/cli/commands/daemon.d.ts.map +1 -0
  8. package/dist/cli/commands/daemon.js +59 -0
  9. package/dist/cli/commands/daemon.js.map +1 -0
  10. package/dist/cli/commands/debug-dump.d.ts.map +1 -1
  11. package/dist/cli/commands/debug-dump.js +8 -10
  12. package/dist/cli/commands/debug-dump.js.map +1 -1
  13. package/dist/cli/commands/helpers.d.ts +18 -0
  14. package/dist/cli/commands/helpers.d.ts.map +1 -0
  15. package/dist/cli/commands/helpers.js +34 -0
  16. package/dist/cli/commands/helpers.js.map +1 -0
  17. package/dist/cli/commands/init.d.ts +1 -1
  18. package/dist/cli/commands/init.d.ts.map +1 -1
  19. package/dist/cli/commands/init.js +3 -4
  20. package/dist/cli/commands/init.js.map +1 -1
  21. package/dist/cli/commands/intercept.d.ts +2 -1
  22. package/dist/cli/commands/intercept.d.ts.map +1 -1
  23. package/dist/cli/commands/intercept.js +74 -30
  24. package/dist/cli/commands/intercept.js.map +1 -1
  25. package/dist/cli/commands/interceptors.d.ts +3 -0
  26. package/dist/cli/commands/interceptors.d.ts.map +1 -0
  27. package/dist/cli/commands/interceptors.js +163 -0
  28. package/dist/cli/commands/interceptors.js.map +1 -0
  29. package/dist/cli/commands/mcp.d.ts +3 -0
  30. package/dist/cli/commands/mcp.d.ts.map +1 -0
  31. package/dist/cli/commands/mcp.js +24 -0
  32. package/dist/cli/commands/mcp.js.map +1 -0
  33. package/dist/cli/commands/off.d.ts +8 -0
  34. package/dist/cli/commands/off.d.ts.map +1 -0
  35. package/dist/cli/commands/off.js +34 -0
  36. package/dist/cli/commands/off.js.map +1 -0
  37. package/dist/cli/commands/on.d.ts +9 -0
  38. package/dist/cli/commands/on.d.ts.map +1 -0
  39. package/dist/cli/commands/on.js +121 -0
  40. package/dist/cli/commands/on.js.map +1 -0
  41. package/dist/cli/commands/project.d.ts.map +1 -1
  42. package/dist/cli/commands/project.js +5 -3
  43. package/dist/cli/commands/project.js.map +1 -1
  44. package/dist/cli/commands/restart.d.ts.map +1 -1
  45. package/dist/cli/commands/restart.js +5 -10
  46. package/dist/cli/commands/restart.js.map +1 -1
  47. package/dist/cli/commands/status.d.ts.map +1 -1
  48. package/dist/cli/commands/status.js +50 -20
  49. package/dist/cli/commands/status.js.map +1 -1
  50. package/dist/cli/commands/stop.d.ts.map +1 -1
  51. package/dist/cli/commands/stop.js +7 -10
  52. package/dist/cli/commands/stop.js.map +1 -1
  53. package/dist/cli/commands/tui.d.ts.map +1 -1
  54. package/dist/cli/commands/tui.js +6 -13
  55. package/dist/cli/commands/tui.js.map +1 -1
  56. package/dist/cli/index.js +12 -7
  57. package/dist/cli/index.js.map +1 -1
  58. package/dist/cli/tui/App.d.ts +7 -2
  59. package/dist/cli/tui/App.d.ts.map +1 -1
  60. package/dist/cli/tui/App.js +490 -33
  61. package/dist/cli/tui/App.js.map +1 -1
  62. package/dist/cli/tui/components/AccordionContent.d.ts +28 -0
  63. package/dist/cli/tui/components/AccordionContent.d.ts.map +1 -0
  64. package/dist/cli/tui/components/AccordionContent.js +87 -0
  65. package/dist/cli/tui/components/AccordionContent.js.map +1 -0
  66. package/dist/cli/tui/components/AccordionPanel.d.ts +38 -0
  67. package/dist/cli/tui/components/AccordionPanel.d.ts.map +1 -0
  68. package/dist/cli/tui/components/AccordionPanel.js +110 -0
  69. package/dist/cli/tui/components/AccordionPanel.js.map +1 -0
  70. package/dist/cli/tui/components/AccordionSection.d.ts +32 -0
  71. package/dist/cli/tui/components/AccordionSection.d.ts.map +1 -0
  72. package/dist/cli/tui/components/AccordionSection.js +41 -0
  73. package/dist/cli/tui/components/AccordionSection.js.map +1 -0
  74. package/dist/cli/tui/components/ExportModal.d.ts +34 -0
  75. package/dist/cli/tui/components/ExportModal.d.ts.map +1 -0
  76. package/dist/cli/tui/components/ExportModal.js +109 -0
  77. package/dist/cli/tui/components/ExportModal.js.map +1 -0
  78. package/dist/cli/tui/components/FilterBar.d.ts +21 -0
  79. package/dist/cli/tui/components/FilterBar.d.ts.map +1 -0
  80. package/dist/cli/tui/components/FilterBar.js +155 -0
  81. package/dist/cli/tui/components/FilterBar.js.map +1 -0
  82. package/dist/cli/tui/components/HelpModal.d.ts +13 -0
  83. package/dist/cli/tui/components/HelpModal.d.ts.map +1 -0
  84. package/dist/cli/tui/components/HelpModal.js +78 -0
  85. package/dist/cli/tui/components/HelpModal.js.map +1 -0
  86. package/dist/cli/tui/components/HintContent.d.ts +25 -0
  87. package/dist/cli/tui/components/HintContent.d.ts.map +1 -0
  88. package/dist/cli/tui/components/HintContent.js +44 -0
  89. package/dist/cli/tui/components/HintContent.js.map +1 -0
  90. package/dist/cli/tui/components/InfoModal.d.ts +15 -0
  91. package/dist/cli/tui/components/InfoModal.d.ts.map +1 -0
  92. package/dist/cli/tui/components/InfoModal.js +17 -0
  93. package/dist/cli/tui/components/InfoModal.js.map +1 -0
  94. package/dist/cli/tui/components/JsonExplorerModal.d.ts +24 -0
  95. package/dist/cli/tui/components/JsonExplorerModal.d.ts.map +1 -0
  96. package/dist/cli/tui/components/JsonExplorerModal.js +311 -0
  97. package/dist/cli/tui/components/JsonExplorerModal.js.map +1 -0
  98. package/dist/cli/tui/components/Modal.d.ts +26 -0
  99. package/dist/cli/tui/components/Modal.d.ts.map +1 -0
  100. package/dist/cli/tui/components/Modal.js +15 -0
  101. package/dist/cli/tui/components/Modal.js.map +1 -0
  102. package/dist/cli/tui/components/Panel.d.ts +19 -0
  103. package/dist/cli/tui/components/Panel.d.ts.map +1 -0
  104. package/dist/cli/tui/components/Panel.js +37 -0
  105. package/dist/cli/tui/components/Panel.js.map +1 -0
  106. package/dist/cli/tui/components/RequestDetails.d.ts +4 -1
  107. package/dist/cli/tui/components/RequestDetails.d.ts.map +1 -1
  108. package/dist/cli/tui/components/RequestDetails.js +9 -5
  109. package/dist/cli/tui/components/RequestDetails.js.map +1 -1
  110. package/dist/cli/tui/components/RequestList.d.ts +9 -3
  111. package/dist/cli/tui/components/RequestList.d.ts.map +1 -1
  112. package/dist/cli/tui/components/RequestList.js +24 -11
  113. package/dist/cli/tui/components/RequestList.js.map +1 -1
  114. package/dist/cli/tui/components/RequestListItem.d.ts +26 -3
  115. package/dist/cli/tui/components/RequestListItem.d.ts.map +1 -1
  116. package/dist/cli/tui/components/RequestListItem.js +86 -9
  117. package/dist/cli/tui/components/RequestListItem.js.map +1 -1
  118. package/dist/cli/tui/components/SaveModal.d.ts +30 -0
  119. package/dist/cli/tui/components/SaveModal.d.ts.map +1 -0
  120. package/dist/cli/tui/components/SaveModal.js +95 -0
  121. package/dist/cli/tui/components/SaveModal.js.map +1 -0
  122. package/dist/cli/tui/components/StatusBar.d.ts +31 -2
  123. package/dist/cli/tui/components/StatusBar.d.ts.map +1 -1
  124. package/dist/cli/tui/components/StatusBar.js +44 -9
  125. package/dist/cli/tui/components/StatusBar.js.map +1 -1
  126. package/dist/cli/tui/components/TextViewerModal.d.ts +19 -0
  127. package/dist/cli/tui/components/TextViewerModal.d.ts.map +1 -0
  128. package/dist/cli/tui/components/TextViewerModal.js +227 -0
  129. package/dist/cli/tui/components/TextViewerModal.js.map +1 -0
  130. package/dist/cli/tui/hooks/useBodyExport.d.ts +26 -0
  131. package/dist/cli/tui/hooks/useBodyExport.d.ts.map +1 -0
  132. package/dist/cli/tui/hooks/useBodyExport.js +173 -0
  133. package/dist/cli/tui/hooks/useBodyExport.js.map +1 -0
  134. package/dist/cli/tui/hooks/useExport.d.ts +13 -2
  135. package/dist/cli/tui/hooks/useExport.d.ts.map +1 -1
  136. package/dist/cli/tui/hooks/useExport.js +46 -40
  137. package/dist/cli/tui/hooks/useExport.js.map +1 -1
  138. package/dist/cli/tui/hooks/useRequests.d.ts +9 -3
  139. package/dist/cli/tui/hooks/useRequests.d.ts.map +1 -1
  140. package/dist/cli/tui/hooks/useRequests.js +61 -15
  141. package/dist/cli/tui/hooks/useRequests.js.map +1 -1
  142. package/dist/cli/tui/hooks/useSaveBinary.d.ts +26 -0
  143. package/dist/cli/tui/hooks/useSaveBinary.d.ts.map +1 -0
  144. package/dist/cli/tui/hooks/useSaveBinary.js +165 -0
  145. package/dist/cli/tui/hooks/useSaveBinary.js.map +1 -0
  146. package/dist/cli/tui/hooks/useSpinner.d.ts +5 -0
  147. package/dist/cli/tui/hooks/useSpinner.d.ts.map +1 -0
  148. package/dist/cli/tui/hooks/useSpinner.js +25 -0
  149. package/dist/cli/tui/hooks/useSpinner.js.map +1 -0
  150. package/dist/cli/tui/utils/binary.d.ts +24 -0
  151. package/dist/cli/tui/utils/binary.d.ts.map +1 -0
  152. package/dist/cli/tui/utils/binary.js +152 -0
  153. package/dist/cli/tui/utils/binary.js.map +1 -0
  154. package/dist/cli/tui/utils/clipboard.d.ts +9 -0
  155. package/dist/cli/tui/utils/clipboard.d.ts.map +1 -0
  156. package/dist/cli/tui/utils/clipboard.js +58 -0
  157. package/dist/cli/tui/utils/clipboard.js.map +1 -0
  158. package/dist/cli/tui/utils/content-type.d.ts +8 -0
  159. package/dist/cli/tui/utils/content-type.d.ts.map +1 -0
  160. package/dist/cli/tui/utils/content-type.js +10 -0
  161. package/dist/cli/tui/utils/content-type.js.map +1 -0
  162. package/dist/cli/tui/utils/curl.d.ts.map +1 -1
  163. package/dist/cli/tui/utils/curl.js +9 -2
  164. package/dist/cli/tui/utils/curl.js.map +1 -1
  165. package/dist/cli/tui/utils/filters.d.ts +6 -0
  166. package/dist/cli/tui/utils/filters.d.ts.map +1 -0
  167. package/dist/cli/tui/utils/filters.js +13 -0
  168. package/dist/cli/tui/utils/filters.js.map +1 -0
  169. package/dist/cli/tui/utils/formatters.d.ts +8 -0
  170. package/dist/cli/tui/utils/formatters.d.ts.map +1 -1
  171. package/dist/cli/tui/utils/formatters.js +85 -0
  172. package/dist/cli/tui/utils/formatters.js.map +1 -1
  173. package/dist/cli/tui/utils/har.d.ts.map +1 -1
  174. package/dist/cli/tui/utils/har.js +3 -25
  175. package/dist/cli/tui/utils/har.js.map +1 -1
  176. package/dist/cli/tui/utils/json-tree.d.ts +69 -0
  177. package/dist/cli/tui/utils/json-tree.d.ts.map +1 -0
  178. package/dist/cli/tui/utils/json-tree.js +339 -0
  179. package/dist/cli/tui/utils/json-tree.js.map +1 -0
  180. package/dist/cli/tui/utils/open-external.d.ts +17 -0
  181. package/dist/cli/tui/utils/open-external.d.ts.map +1 -0
  182. package/dist/cli/tui/utils/open-external.js +57 -0
  183. package/dist/cli/tui/utils/open-external.js.map +1 -0
  184. package/dist/cli/tui/utils/syntax-highlight.d.ts +16 -0
  185. package/dist/cli/tui/utils/syntax-highlight.d.ts.map +1 -0
  186. package/dist/cli/tui/utils/syntax-highlight.js +64 -0
  187. package/dist/cli/tui/utils/syntax-highlight.js.map +1 -0
  188. package/dist/daemon/control.d.ts +3 -49
  189. package/dist/daemon/control.d.ts.map +1 -1
  190. package/dist/daemon/control.js +183 -141
  191. package/dist/daemon/control.js.map +1 -1
  192. package/dist/daemon/htpx-client.d.ts +8 -0
  193. package/dist/daemon/htpx-client.d.ts.map +1 -0
  194. package/dist/daemon/htpx-client.js +25 -0
  195. package/dist/daemon/htpx-client.js.map +1 -0
  196. package/dist/daemon/index.js +50 -2
  197. package/dist/daemon/index.js.map +1 -1
  198. package/dist/daemon/interceptor-loader.d.ts +30 -0
  199. package/dist/daemon/interceptor-loader.d.ts.map +1 -0
  200. package/dist/daemon/interceptor-loader.js +249 -0
  201. package/dist/daemon/interceptor-loader.js.map +1 -0
  202. package/dist/daemon/interceptor-runner.d.ts +39 -0
  203. package/dist/daemon/interceptor-runner.d.ts.map +1 -0
  204. package/dist/daemon/interceptor-runner.js +312 -0
  205. package/dist/daemon/interceptor-runner.js.map +1 -0
  206. package/dist/daemon/proxy.d.ts +12 -0
  207. package/dist/daemon/proxy.d.ts.map +1 -1
  208. package/dist/daemon/proxy.js +121 -10
  209. package/dist/daemon/proxy.js.map +1 -1
  210. package/dist/daemon/storage.d.ts +64 -2
  211. package/dist/daemon/storage.d.ts.map +1 -1
  212. package/dist/daemon/storage.js +527 -12
  213. package/dist/daemon/storage.js.map +1 -1
  214. package/dist/interceptors.d.ts +2 -0
  215. package/dist/interceptors.d.ts.map +1 -0
  216. package/dist/interceptors.js +2 -0
  217. package/dist/interceptors.js.map +1 -0
  218. package/dist/mcp/server.d.ts +110 -0
  219. package/dist/mcp/server.d.ts.map +1 -0
  220. package/dist/mcp/server.js +806 -0
  221. package/dist/mcp/server.js.map +1 -0
  222. package/dist/shared/config.d.ts +21 -0
  223. package/dist/shared/config.d.ts.map +1 -0
  224. package/dist/shared/config.js +83 -0
  225. package/dist/shared/config.js.map +1 -0
  226. package/dist/shared/content-type.d.ts +64 -0
  227. package/dist/shared/content-type.d.ts.map +1 -0
  228. package/dist/shared/content-type.js +145 -0
  229. package/dist/shared/content-type.js.map +1 -0
  230. package/dist/shared/control-client.d.ts +144 -0
  231. package/dist/shared/control-client.d.ts.map +1 -0
  232. package/dist/shared/control-client.js +272 -0
  233. package/dist/shared/control-client.js.map +1 -0
  234. package/dist/shared/daemon.d.ts.map +1 -1
  235. package/dist/shared/daemon.js +17 -4
  236. package/dist/shared/daemon.js.map +1 -1
  237. package/dist/shared/logger.d.ts +21 -5
  238. package/dist/shared/logger.d.ts.map +1 -1
  239. package/dist/shared/logger.js +100 -21
  240. package/dist/shared/logger.js.map +1 -1
  241. package/dist/shared/project.d.ts +16 -3
  242. package/dist/shared/project.d.ts.map +1 -1
  243. package/dist/shared/project.js +45 -5
  244. package/dist/shared/project.js.map +1 -1
  245. package/dist/shared/proxy-info.d.ts +10 -0
  246. package/dist/shared/proxy-info.d.ts.map +1 -0
  247. package/dist/shared/proxy-info.js +15 -0
  248. package/dist/shared/proxy-info.js.map +1 -0
  249. package/dist/shared/types.d.ts +95 -0
  250. package/dist/shared/types.d.ts.map +1 -1
  251. package/package.json +24 -5
  252. package/skills/htpx/SKILL.md +228 -0
@@ -1,6 +1,10 @@
1
1
  import Database from "better-sqlite3";
2
2
  import { v4 as uuidv4 } from "uuid";
3
3
  import { createLogger } from "../shared/logger.js";
4
+ import { normaliseContentType, buildTextContentTypeSqlCondition, buildJsonContentTypeSqlCondition, } from "../shared/content-type.js";
5
+ import { DEFAULT_MAX_STORED_REQUESTS } from "../shared/config.js";
6
+ const DEFAULT_QUERY_LIMIT = 1000;
7
+ const EVICTION_CHECK_INTERVAL = 100;
4
8
  const SCHEMA = `
5
9
  CREATE TABLE IF NOT EXISTS sessions (
6
10
  id TEXT PRIMARY KEY,
@@ -20,10 +24,16 @@ CREATE TABLE IF NOT EXISTS requests (
20
24
  path TEXT NOT NULL,
21
25
  request_headers TEXT,
22
26
  request_body BLOB,
27
+ request_body_truncated INTEGER DEFAULT 0,
23
28
  response_status INTEGER,
24
29
  response_headers TEXT,
25
30
  response_body BLOB,
31
+ response_body_truncated INTEGER DEFAULT 0,
26
32
  duration_ms INTEGER,
33
+ request_content_type TEXT,
34
+ response_content_type TEXT,
35
+ intercepted_by TEXT,
36
+ interception_type TEXT CHECK(interception_type IN ('modified', 'mocked')),
27
37
  created_at INTEGER DEFAULT (unixepoch()),
28
38
  FOREIGN KEY (session_id) REFERENCES sessions(id)
29
39
  );
@@ -31,18 +41,226 @@ CREATE TABLE IF NOT EXISTS requests (
31
41
  CREATE INDEX IF NOT EXISTS idx_requests_timestamp ON requests(timestamp DESC);
32
42
  CREATE INDEX IF NOT EXISTS idx_requests_session ON requests(session_id);
33
43
  CREATE INDEX IF NOT EXISTS idx_requests_label ON requests(label);
44
+ CREATE INDEX IF NOT EXISTS idx_requests_method ON requests(method);
45
+ CREATE INDEX IF NOT EXISTS idx_requests_status ON requests(response_status);
46
+ CREATE INDEX IF NOT EXISTS idx_requests_host ON requests(host);
34
47
  `;
48
+ const MIGRATIONS = [
49
+ {
50
+ version: 1,
51
+ description: "Add body truncation tracking columns",
52
+ sql: `
53
+ ALTER TABLE requests ADD COLUMN request_body_truncated INTEGER DEFAULT 0;
54
+ ALTER TABLE requests ADD COLUMN response_body_truncated INTEGER DEFAULT 0;
55
+ `,
56
+ },
57
+ {
58
+ version: 2,
59
+ description: "Add indices for method and status filtering",
60
+ sql: `
61
+ CREATE INDEX IF NOT EXISTS idx_requests_method ON requests(method);
62
+ CREATE INDEX IF NOT EXISTS idx_requests_status ON requests(response_status);
63
+ `,
64
+ },
65
+ {
66
+ version: 3,
67
+ description: "Add content-type columns for efficient body searching",
68
+ sql: `
69
+ ALTER TABLE requests ADD COLUMN request_content_type TEXT;
70
+ ALTER TABLE requests ADD COLUMN response_content_type TEXT;
71
+ `,
72
+ },
73
+ {
74
+ version: 4,
75
+ description: "Add index on host for host-based filtering",
76
+ sql: `CREATE INDEX IF NOT EXISTS idx_requests_host ON requests(host);`,
77
+ },
78
+ {
79
+ version: 5,
80
+ description: "Add interceptor tracking columns",
81
+ sql: `
82
+ ALTER TABLE requests ADD COLUMN intercepted_by TEXT;
83
+ ALTER TABLE requests ADD COLUMN interception_type TEXT;
84
+ `,
85
+ },
86
+ ];
87
+ const STATUS_RANGE_MULTIPLIER = 100;
88
+ const MIN_HTTP_STATUS = 100;
89
+ const MAX_HTTP_STATUS = 599;
90
+ /**
91
+ * Escape SQL LIKE wildcards in user input to prevent unintended pattern matching.
92
+ */
93
+ function escapeLikeWildcards(input) {
94
+ return input.replace(/\\/g, "\\\\").replace(/%/g, "\\%").replace(/_/g, "\\_");
95
+ }
96
+ /**
97
+ * Apply status range filter condition. Supports three formats:
98
+ * - Nxx pattern (e.g. "2xx") → range from N00 to (N+1)00
99
+ * - Exact code (e.g. "401") → exact match
100
+ * - Numeric range (e.g. "500-503") → inclusive range
101
+ */
102
+ function applyStatusCondition(conditions, params, statusRange) {
103
+ // Nxx pattern — e.g. "2xx", "4xx"
104
+ if (/^[1-5]xx$/.test(statusRange)) {
105
+ const firstDigit = parseInt(statusRange.charAt(0), 10);
106
+ const lower = firstDigit * STATUS_RANGE_MULTIPLIER;
107
+ const upper = (firstDigit + 1) * STATUS_RANGE_MULTIPLIER;
108
+ conditions.push("response_status >= ? AND response_status < ?");
109
+ params.push(lower, upper);
110
+ return;
111
+ }
112
+ // Numeric range — e.g. "500-503"
113
+ const rangeMatch = statusRange.match(/^(\d{3})-(\d{3})$/);
114
+ if (rangeMatch && rangeMatch[1] && rangeMatch[2]) {
115
+ const low = parseInt(rangeMatch[1], 10);
116
+ const high = parseInt(rangeMatch[2], 10);
117
+ if (low >= MIN_HTTP_STATUS && high <= MAX_HTTP_STATUS && low <= high) {
118
+ conditions.push("response_status >= ? AND response_status <= ?");
119
+ params.push(low, high);
120
+ return;
121
+ }
122
+ }
123
+ // Exact code — e.g. "401"
124
+ if (/^\d{3}$/.test(statusRange)) {
125
+ const code = parseInt(statusRange, 10);
126
+ if (code >= MIN_HTTP_STATUS && code <= MAX_HTTP_STATUS) {
127
+ conditions.push("response_status = ?");
128
+ params.push(code);
129
+ return;
130
+ }
131
+ }
132
+ // Unrecognised format — silently ignored at the storage layer
133
+ // (validation should happen upstream in MCP/control server)
134
+ }
135
+ /**
136
+ * Apply RequestFilter conditions to an existing SQL conditions/params array.
137
+ * Mutates both arrays in place.
138
+ */
139
+ function applyFilterConditions(conditions, params, filter) {
140
+ if (!filter)
141
+ return;
142
+ if (filter.methods && filter.methods.length > 0) {
143
+ const placeholders = filter.methods.map(() => "?").join(", ");
144
+ conditions.push(`method IN (${placeholders})`);
145
+ params.push(...filter.methods);
146
+ }
147
+ if (filter.statusRange) {
148
+ applyStatusCondition(conditions, params, filter.statusRange);
149
+ }
150
+ if (filter.search) {
151
+ const escaped = escapeLikeWildcards(filter.search);
152
+ const pattern = `%${escaped}%`;
153
+ conditions.push("(url LIKE ? ESCAPE '\\' OR path LIKE ? ESCAPE '\\')");
154
+ params.push(pattern, pattern);
155
+ }
156
+ if (filter.host) {
157
+ if (filter.host.startsWith(".")) {
158
+ // Suffix match — e.g. ".example.com" matches "api.example.com"
159
+ const escaped = escapeLikeWildcards(filter.host);
160
+ conditions.push("host LIKE ? ESCAPE '\\'");
161
+ params.push(`%${escaped}`);
162
+ }
163
+ else {
164
+ // Exact match
165
+ conditions.push("host = ?");
166
+ params.push(filter.host);
167
+ }
168
+ }
169
+ if (filter.pathPrefix) {
170
+ const escaped = escapeLikeWildcards(filter.pathPrefix);
171
+ conditions.push("path LIKE ? ESCAPE '\\'");
172
+ params.push(`${escaped}%`);
173
+ }
174
+ if (filter.since !== undefined) {
175
+ conditions.push("timestamp >= ?");
176
+ params.push(filter.since);
177
+ }
178
+ if (filter.before !== undefined) {
179
+ conditions.push("timestamp < ?");
180
+ params.push(filter.before);
181
+ }
182
+ if (filter.interceptedBy) {
183
+ conditions.push("intercepted_by = ?");
184
+ params.push(filter.interceptedBy);
185
+ }
186
+ if (filter.headerName) {
187
+ const name = filter.headerName.toLowerCase();
188
+ const jsonPath = `$."${name}"`;
189
+ const target = filter.headerTarget ?? "both";
190
+ if (filter.headerValue !== undefined) {
191
+ // Name + value match
192
+ if (target === "request") {
193
+ conditions.push("json_extract(request_headers, ?) = ?");
194
+ params.push(jsonPath, filter.headerValue);
195
+ }
196
+ else if (target === "response") {
197
+ conditions.push("json_extract(response_headers, ?) = ?");
198
+ params.push(jsonPath, filter.headerValue);
199
+ }
200
+ else {
201
+ conditions.push("(json_extract(request_headers, ?) = ? OR json_extract(response_headers, ?) = ?)");
202
+ params.push(jsonPath, filter.headerValue, jsonPath, filter.headerValue);
203
+ }
204
+ }
205
+ else {
206
+ // Name-only existence check
207
+ if (target === "request") {
208
+ conditions.push("json_extract(request_headers, ?) IS NOT NULL");
209
+ params.push(jsonPath);
210
+ }
211
+ else if (target === "response") {
212
+ conditions.push("json_extract(response_headers, ?) IS NOT NULL");
213
+ params.push(jsonPath);
214
+ }
215
+ else {
216
+ conditions.push("(json_extract(request_headers, ?) IS NOT NULL OR json_extract(response_headers, ?) IS NOT NULL)");
217
+ params.push(jsonPath, jsonPath);
218
+ }
219
+ }
220
+ }
221
+ }
35
222
  export class RequestRepository {
36
223
  db;
37
224
  logger;
38
- constructor(dbPath, projectRoot, logLevel) {
225
+ maxStoredRequests;
226
+ insertsSinceLastEvictionCheck = 0;
227
+ constructor(dbPath, projectRoot, logLevel, options) {
39
228
  this.db = new Database(dbPath);
40
229
  this.db.pragma("journal_mode = WAL");
41
230
  this.db.exec(SCHEMA);
231
+ this.maxStoredRequests = options?.maxStoredRequests ?? DEFAULT_MAX_STORED_REQUESTS;
232
+ // Fresh databases already have the latest schema — stamp to latest version
233
+ // so migrations don't try to re-apply what's already in the CREATE TABLE.
234
+ const currentVersion = this.db.pragma("user_version", { simple: true });
235
+ if (currentVersion === 0) {
236
+ const hasData = this.db.prepare("SELECT COUNT(*) as count FROM requests").get().count > 0;
237
+ if (!hasData) {
238
+ const lastMigration = MIGRATIONS[MIGRATIONS.length - 1];
239
+ const latestVersion = lastMigration ? lastMigration.version : 0;
240
+ this.db.pragma(`user_version = ${latestVersion}`);
241
+ }
242
+ }
243
+ this.applyMigrations();
42
244
  if (projectRoot) {
43
245
  this.logger = createLogger("storage", projectRoot, logLevel);
44
246
  }
45
247
  }
248
+ /**
249
+ * Apply pending database migrations using SQLite's user_version pragma for tracking.
250
+ */
251
+ applyMigrations() {
252
+ const currentVersion = this.db.pragma("user_version", { simple: true });
253
+ const pending = MIGRATIONS.filter((m) => m.version > currentVersion);
254
+ if (pending.length === 0)
255
+ return;
256
+ const applyAll = this.db.transaction(() => {
257
+ for (const migration of pending) {
258
+ this.db.exec(migration.sql);
259
+ this.db.pragma(`user_version = ${migration.version}`);
260
+ }
261
+ });
262
+ applyAll();
263
+ }
46
264
  /**
47
265
  * Register a new session.
48
266
  */
@@ -122,33 +340,49 @@ export class RequestRepository {
122
340
  */
123
341
  saveRequest(request) {
124
342
  const id = uuidv4();
343
+ const requestContentType = request.requestHeaders
344
+ ? normaliseContentType(request.requestHeaders["content-type"])
345
+ : null;
125
346
  const stmt = this.db.prepare(`
126
347
  INSERT INTO requests (
127
348
  id, session_id, label, timestamp, method, url, host, path,
128
- request_headers, request_body, response_status, response_headers,
129
- response_body, duration_ms
349
+ request_headers, request_body, request_body_truncated, response_status, response_headers,
350
+ response_body, response_body_truncated, duration_ms, request_content_type
130
351
  )
131
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
352
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
132
353
  `);
133
- stmt.run(id, request.sessionId, request.label ?? null, request.timestamp, request.method, request.url, request.host, request.path, request.requestHeaders ? JSON.stringify(request.requestHeaders) : null, request.requestBody ?? null, request.responseStatus ?? null, request.responseHeaders ? JSON.stringify(request.responseHeaders) : null, request.responseBody ?? null, request.durationMs ?? null);
354
+ stmt.run(id, request.sessionId, request.label ?? null, request.timestamp, request.method, request.url, request.host, request.path, request.requestHeaders ? JSON.stringify(request.requestHeaders) : null, request.requestBody ?? null, request.requestBodyTruncated ? 1 : 0, request.responseStatus ?? null, request.responseHeaders ? JSON.stringify(request.responseHeaders) : null, request.responseBody ?? null, request.responseBodyTruncated ? 1 : 0, request.durationMs ?? null, requestContentType);
134
355
  this.logger?.debug("Request saved", {
135
356
  id,
136
357
  sessionId: request.sessionId,
137
358
  method: request.method,
138
359
  url: request.url,
139
360
  });
361
+ this.evictIfNeeded();
140
362
  return id;
141
363
  }
142
364
  /**
143
365
  * Update a request with response data.
144
366
  */
145
367
  updateRequestResponse(id, response) {
368
+ const responseContentType = normaliseContentType(response.headers["content-type"]);
146
369
  const stmt = this.db.prepare(`
147
370
  UPDATE requests
148
- SET response_status = ?, response_headers = ?, response_body = ?, duration_ms = ?
371
+ SET response_status = ?, response_headers = ?, response_body = ?, response_body_truncated = ?, duration_ms = ?, response_content_type = ?
149
372
  WHERE id = ?
150
373
  `);
151
- stmt.run(response.status, JSON.stringify(response.headers), response.body ?? null, response.durationMs, id);
374
+ stmt.run(response.status, JSON.stringify(response.headers), response.body ?? null, response.responseBodyTruncated ? 1 : 0, response.durationMs, responseContentType, id);
375
+ }
376
+ /**
377
+ * Update a request with interceptor metadata.
378
+ */
379
+ updateRequestInterception(id, interceptedBy, interceptionType) {
380
+ const stmt = this.db.prepare(`
381
+ UPDATE requests
382
+ SET intercepted_by = ?, interception_type = ?
383
+ WHERE id = ?
384
+ `);
385
+ stmt.run(interceptedBy, interceptionType, id);
152
386
  }
153
387
  /**
154
388
  * Get a request by ID.
@@ -174,8 +408,9 @@ export class RequestRepository {
174
408
  conditions.push("label = ?");
175
409
  params.push(options.label);
176
410
  }
411
+ applyFilterConditions(conditions, params, options.filter);
177
412
  const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
178
- const limit = options.limit ?? 1000;
413
+ const limit = options.limit ?? DEFAULT_QUERY_LIMIT;
179
414
  const offset = options.offset ?? 0;
180
415
  const stmt = this.db.prepare(`
181
416
  SELECT * FROM requests
@@ -187,6 +422,50 @@ export class RequestRepository {
187
422
  const rows = stmt.all(...params);
188
423
  return rows.map((row) => this.rowToRequest(row));
189
424
  }
425
+ /**
426
+ * List request summaries (excludes body/header data for performance).
427
+ * Use this for list views where full request data isn't needed.
428
+ */
429
+ listRequestsSummary(options = {}) {
430
+ const conditions = [];
431
+ const params = [];
432
+ if (options.sessionId) {
433
+ conditions.push("session_id = ?");
434
+ params.push(options.sessionId);
435
+ }
436
+ if (options.label) {
437
+ conditions.push("label = ?");
438
+ params.push(options.label);
439
+ }
440
+ applyFilterConditions(conditions, params, options.filter);
441
+ const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
442
+ const limit = options.limit ?? DEFAULT_QUERY_LIMIT;
443
+ const offset = options.offset ?? 0;
444
+ const stmt = this.db.prepare(`
445
+ SELECT
446
+ id,
447
+ session_id,
448
+ label,
449
+ timestamp,
450
+ method,
451
+ url,
452
+ host,
453
+ path,
454
+ response_status,
455
+ duration_ms,
456
+ COALESCE(LENGTH(request_body), 0) as request_body_size,
457
+ COALESCE(LENGTH(response_body), 0) as response_body_size,
458
+ intercepted_by,
459
+ interception_type
460
+ FROM requests
461
+ ${whereClause}
462
+ ORDER BY timestamp DESC
463
+ LIMIT ? OFFSET ?
464
+ `);
465
+ params.push(limit, offset);
466
+ const rows = stmt.all(...params);
467
+ return rows.map((row) => this.rowToSummary(row));
468
+ }
190
469
  /**
191
470
  * Count requests, optionally filtered by session or label.
192
471
  */
@@ -201,6 +480,7 @@ export class RequestRepository {
201
480
  conditions.push("label = ?");
202
481
  params.push(options.label);
203
482
  }
483
+ applyFilterConditions(conditions, params, options.filter);
204
484
  const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
205
485
  const stmt = this.db.prepare(`
206
486
  SELECT COUNT(*) as count FROM requests ${whereClause}
@@ -208,18 +488,249 @@ export class RequestRepository {
208
488
  const result = stmt.get(...params);
209
489
  return result.count;
210
490
  }
491
+ /**
492
+ * Search through request/response body content for a text pattern.
493
+ * Only searches text-based bodies (not binary).
494
+ */
495
+ searchBodies(options) {
496
+ const conditions = [];
497
+ const params = [];
498
+ const escaped = escapeLikeWildcards(options.query);
499
+ const pattern = `%${escaped}%`;
500
+ // Build content-type conditions — only search text-based bodies
501
+ const reqCt = buildTextContentTypeSqlCondition("request_content_type");
502
+ const resCt = buildTextContentTypeSqlCondition("response_content_type");
503
+ // Search in both request and response bodies, but only where the content type is text-based
504
+ conditions.push(`((${reqCt.clause} AND CAST(request_body AS TEXT) LIKE ? ESCAPE '\\') OR (${resCt.clause} AND CAST(response_body AS TEXT) LIKE ? ESCAPE '\\'))`);
505
+ params.push(...reqCt.params, pattern, ...resCt.params, pattern);
506
+ applyFilterConditions(conditions, params, options.filter);
507
+ const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
508
+ const limit = options.limit ?? DEFAULT_QUERY_LIMIT;
509
+ const offset = options.offset ?? 0;
510
+ const stmt = this.db.prepare(`
511
+ SELECT
512
+ id,
513
+ session_id,
514
+ label,
515
+ timestamp,
516
+ method,
517
+ url,
518
+ host,
519
+ path,
520
+ response_status,
521
+ duration_ms,
522
+ COALESCE(LENGTH(request_body), 0) as request_body_size,
523
+ COALESCE(LENGTH(response_body), 0) as response_body_size,
524
+ intercepted_by,
525
+ interception_type
526
+ FROM requests
527
+ ${whereClause}
528
+ ORDER BY timestamp DESC
529
+ LIMIT ? OFFSET ?
530
+ `);
531
+ params.push(limit, offset);
532
+ const rows = stmt.all(...params);
533
+ return rows.map((row) => this.rowToSummary(row));
534
+ }
535
+ /**
536
+ * Query JSON bodies using SQLite's json_extract.
537
+ * Only queries rows with JSON content types.
538
+ */
539
+ queryJsonBodies(options) {
540
+ const target = options.target ?? "both";
541
+ const conditions = [];
542
+ const params = [];
543
+ // Build the JSON extraction expressions per target
544
+ const extractParts = [];
545
+ if (target === "request" || target === "both") {
546
+ const reqCt = buildJsonContentTypeSqlCondition("request_content_type");
547
+ const reqExtract = `CASE WHEN ${reqCt.clause} THEN json_extract(CAST(request_body AS TEXT), ?) ELSE NULL END`;
548
+ extractParts.push({ sql: reqExtract, ctParams: reqCt.params, column: "request" });
549
+ }
550
+ if (target === "response" || target === "both") {
551
+ const resCt = buildJsonContentTypeSqlCondition("response_content_type");
552
+ const resExtract = `CASE WHEN ${resCt.clause} THEN json_extract(CAST(response_body AS TEXT), ?) ELSE NULL END`;
553
+ extractParts.push({ sql: resExtract, ctParams: resCt.params, column: "response" });
554
+ }
555
+ // Build the select with extracted value, preferring request over response for "both"
556
+ const extracts = extractParts;
557
+ const extractSelectParts = [];
558
+ const extractSelectParams = [];
559
+ for (const part of extracts) {
560
+ extractSelectParts.push(part.sql);
561
+ extractSelectParams.push(...part.ctParams, options.jsonPath);
562
+ }
563
+ // COALESCE so "both" returns the first non-null value
564
+ const extractedValueExpr = extractSelectParts.length > 1
565
+ ? `COALESCE(${extractSelectParts.join(", ")})`
566
+ : (extractSelectParts[0] ?? "NULL");
567
+ // Content-type restriction: at least one target must have a JSON content type
568
+ const ctConditions = [];
569
+ const ctParams = [];
570
+ if (target === "request" || target === "both") {
571
+ const reqCt = buildJsonContentTypeSqlCondition("request_content_type");
572
+ ctConditions.push(reqCt.clause);
573
+ ctParams.push(...reqCt.params);
574
+ }
575
+ if (target === "response" || target === "both") {
576
+ const resCt = buildJsonContentTypeSqlCondition("response_content_type");
577
+ ctConditions.push(resCt.clause);
578
+ ctParams.push(...resCt.params);
579
+ }
580
+ conditions.push(`(${ctConditions.join(" OR ")})`);
581
+ params.push(...ctParams);
582
+ applyFilterConditions(conditions, params, options.filter);
583
+ const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
584
+ const limit = options.limit ?? DEFAULT_QUERY_LIMIT;
585
+ const offset = options.offset ?? 0;
586
+ // Build the full query — we use a subquery to compute extracted_value,
587
+ // then filter on it in the outer query
588
+ let sql;
589
+ const allParams = [];
590
+ if (options.value !== undefined) {
591
+ sql = `
592
+ SELECT * FROM (
593
+ SELECT
594
+ id,
595
+ session_id,
596
+ label,
597
+ timestamp,
598
+ method,
599
+ url,
600
+ host,
601
+ path,
602
+ response_status,
603
+ duration_ms,
604
+ COALESCE(LENGTH(request_body), 0) as request_body_size,
605
+ COALESCE(LENGTH(response_body), 0) as response_body_size,
606
+ ${extractedValueExpr} as extracted_value
607
+ FROM requests
608
+ ${whereClause}
609
+ ) sub
610
+ WHERE extracted_value = ?
611
+ ORDER BY timestamp DESC
612
+ LIMIT ? OFFSET ?
613
+ `;
614
+ allParams.push(...extractSelectParams, ...params, options.value, limit, offset);
615
+ }
616
+ else {
617
+ sql = `
618
+ SELECT * FROM (
619
+ SELECT
620
+ id,
621
+ session_id,
622
+ label,
623
+ timestamp,
624
+ method,
625
+ url,
626
+ host,
627
+ path,
628
+ response_status,
629
+ duration_ms,
630
+ COALESCE(LENGTH(request_body), 0) as request_body_size,
631
+ COALESCE(LENGTH(response_body), 0) as response_body_size,
632
+ ${extractedValueExpr} as extracted_value
633
+ FROM requests
634
+ ${whereClause}
635
+ ) sub
636
+ WHERE extracted_value IS NOT NULL
637
+ ORDER BY timestamp DESC
638
+ LIMIT ? OFFSET ?
639
+ `;
640
+ allParams.push(...extractSelectParams, ...params, limit, offset);
641
+ }
642
+ const stmt = this.db.prepare(sql);
643
+ const rows = stmt.all(...allParams);
644
+ return rows.map((row) => ({
645
+ id: row.id,
646
+ sessionId: row.session_id,
647
+ label: row.label ?? undefined,
648
+ timestamp: row.timestamp,
649
+ method: row.method,
650
+ url: row.url,
651
+ host: row.host,
652
+ path: row.path,
653
+ responseStatus: row.response_status ?? undefined,
654
+ durationMs: row.duration_ms ?? undefined,
655
+ requestBodySize: row.request_body_size,
656
+ responseBodySize: row.response_body_size,
657
+ extractedValue: row.extracted_value,
658
+ }));
659
+ }
211
660
  /**
212
661
  * Delete all requests (useful for cleanup).
213
662
  */
214
663
  clearRequests() {
215
664
  this.db.exec("DELETE FROM requests");
216
665
  }
666
+ /**
667
+ * Reclaim disk space by checkpointing the WAL and vacuuming.
668
+ * Intended for use during shutdown — not suitable for the hot path.
669
+ */
670
+ compactDatabase() {
671
+ this.db.pragma("wal_checkpoint(TRUNCATE)");
672
+ this.db.exec("VACUUM");
673
+ }
217
674
  /**
218
675
  * Close the database connection.
219
676
  */
220
677
  close() {
221
678
  this.db.close();
222
679
  }
680
+ /**
681
+ * Check whether the request count exceeds the cap and evict oldest rows.
682
+ * Only runs the actual COUNT query every EVICTION_CHECK_INTERVAL inserts
683
+ * to keep the hot path cheap.
684
+ */
685
+ evictIfNeeded() {
686
+ this.insertsSinceLastEvictionCheck++;
687
+ if (this.insertsSinceLastEvictionCheck < EVICTION_CHECK_INTERVAL) {
688
+ return;
689
+ }
690
+ this.insertsSinceLastEvictionCheck = 0;
691
+ const { count } = this.db.prepare("SELECT COUNT(*) as count FROM requests").get();
692
+ if (count <= this.maxStoredRequests) {
693
+ return;
694
+ }
695
+ const excess = count - this.maxStoredRequests;
696
+ this.db
697
+ .prepare(`DELETE FROM requests WHERE id IN (
698
+ SELECT id FROM requests ORDER BY timestamp ASC LIMIT ?
699
+ )`)
700
+ .run(excess);
701
+ this.logger?.debug("Evicted old requests", {
702
+ evicted: excess,
703
+ remaining: this.maxStoredRequests,
704
+ });
705
+ }
706
+ rowToSummary(row) {
707
+ return {
708
+ id: row.id,
709
+ sessionId: row.session_id,
710
+ label: row.label ?? undefined,
711
+ timestamp: row.timestamp,
712
+ method: row.method,
713
+ url: row.url,
714
+ host: row.host,
715
+ path: row.path,
716
+ responseStatus: row.response_status ?? undefined,
717
+ durationMs: row.duration_ms ?? undefined,
718
+ requestBodySize: row.request_body_size,
719
+ responseBodySize: row.response_body_size,
720
+ interceptedBy: row.intercepted_by ?? undefined,
721
+ interceptionType: row.interception_type === "modified" || row.interception_type === "mocked"
722
+ ? row.interception_type
723
+ : undefined,
724
+ };
725
+ }
726
+ safeParseHeaders(json) {
727
+ try {
728
+ return JSON.parse(json);
729
+ }
730
+ catch {
731
+ return {};
732
+ }
733
+ }
223
734
  rowToRequest(row) {
224
735
  return {
225
736
  id: row.id,
@@ -230,16 +741,20 @@ export class RequestRepository {
230
741
  url: row.url,
231
742
  host: row.host,
232
743
  path: row.path,
233
- requestHeaders: row.request_headers
234
- ? JSON.parse(row.request_headers)
235
- : {},
744
+ requestHeaders: row.request_headers ? this.safeParseHeaders(row.request_headers) : {},
236
745
  requestBody: row.request_body ?? undefined,
746
+ requestBodyTruncated: row.request_body_truncated === 1,
237
747
  responseStatus: row.response_status ?? undefined,
238
748
  responseHeaders: row.response_headers
239
- ? JSON.parse(row.response_headers)
749
+ ? this.safeParseHeaders(row.response_headers)
240
750
  : undefined,
241
751
  responseBody: row.response_body ?? undefined,
752
+ responseBodyTruncated: row.response_body_truncated === 1,
242
753
  durationMs: row.duration_ms ?? undefined,
754
+ interceptedBy: row.intercepted_by ?? undefined,
755
+ interceptionType: row.interception_type === "modified" || row.interception_type === "mocked"
756
+ ? row.interception_type
757
+ : undefined,
243
758
  };
244
759
  }
245
760
  }