Skip to content

Commit abde358

Browse files
authored
feat: export all traffics as MD/HAR/cURL (#16)
1 parent 124efa3 commit abde358

File tree

4 files changed

+187
-70
lines changed

4 files changed

+187
-70
lines changed

assets/index.html

Lines changed: 73 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -247,6 +247,14 @@
247247
<div data-tab="ws" class="tab hidden">Websocket</div>
248248
<div data-tab="err" class="tab hidden">Error</div>
249249
<div class="toolbox">
250+
<div class="dropdown export-dropdown">
251+
<button class="dropbtn">Export▾</button>
252+
<div class="dropdown-content">
253+
<a data-kind="markdown">Export all as Markdown</a>
254+
<a data-kind="curl">Export all as cURL</a>
255+
<a data-kind="har">Export all as HAR</a>
256+
</div>
257+
</div>
250258
<div class="dropdown copy-dropdown">
251259
<button class="dropbtn">Copy▾</button>
252260
<div class="dropdown-content">
@@ -289,6 +297,10 @@
289297
* @property {string} value
290298
*/
291299

300+
/**
301+
* @typedef {"markdown"|"curl"|"har"} ExportKind
302+
*/
303+
292304
/**
293305
* @typedef {"markdown"|"curl"|"har"|"res-body"} CopyKind
294306
*/
@@ -361,8 +373,9 @@
361373
const BASE_URL = "/__proxyfor__";
362374

363375
const subscribeTrafficsEndpoint = `${BASE_URL}/subscribe/traffics`;
364-
const getTrafficEndpoint = id => `${BASE_URL}/traffic/${id}`;
365376
const subscribeWebsocketEndpoint = id => `${BASE_URL}/subscribe/websocket/${id}`;
377+
const getTrafficEndpoint = id => `${BASE_URL}/traffic/${id}`;
378+
const listTrafficsEndpoint = `${BASE_URL}/traffics`;
366379

367380
/**
368381
* @type {HTMLTableElement}
@@ -377,6 +390,16 @@
377390
*/
378391
const $mainView = document.querySelector(".main-view");
379392

393+
/**
394+
* @type {HTMLButtonElement}
395+
*/
396+
const $exportDropbtn = document.querySelector(".export-dropdown .dropbtn");
397+
398+
/**
399+
* @type {HTMLDivElement}
400+
*/
401+
const $exportDropdownContent = document.querySelector(".export-dropdown .dropdown-content");
402+
380403
/**
381404
* @type {HTMLButtonElement}
382405
*/
@@ -450,8 +473,21 @@
450473
if (tab) handleSelectedTab(tab, true);
451474
}
452475
});
476+
$exportDropbtn.addEventListener("click", (e) => {
477+
$exportDropdownContent.style.display = "block";
478+
$copyDropdownContent.style.display = "none";
479+
});
480+
$exportDropdownContent.addEventListener("click", (e) => {
481+
$exportDropdownContent.style.display = "none";
482+
const $kind = e.target.closest("[data-kind]");
483+
if ($kind) {
484+
const kind = $kind.getAttribute("data-kind");
485+
if (kind) handleExport(kind);
486+
}
487+
});
453488
$copyDropbtn.addEventListener("click", (e) => {
454489
$copyDropdownContent.style.display = "block";
490+
$exportDropdownContent.style.display = "none";
455491
});
456492
$copyDropdownContent.addEventListener("click", (e) => {
457493
$copyDropdownContent.style.display = "none";
@@ -463,6 +499,7 @@
463499
});
464500
document.addEventListener("click", function (e) {
465501
if (!e.target.closest(".dropdown")) {
502+
$exportDropdownContent.style.display = "none";
466503
$copyDropdownContent.style.display = "none";
467504
}
468505
});
@@ -508,6 +545,41 @@
508545
$trafficTableBody.insertAdjacentHTML("beforeend", tmplTrafficTableRow(id, uri, method, status));
509546
}
510547

548+
/**
549+
* @param {ExportKind} kind
550+
*/
551+
function handleExport(kind) {
552+
fetch(listTrafficsEndpoint + `?${kind}`)
553+
.then(async res => {
554+
const contentType = res.headers.get('content-type');
555+
const text = await res.text();
556+
let filename = 'txt';
557+
switch (kind) {
558+
case "markdown":
559+
filename = 'traffics.md';
560+
break;
561+
case "curl":
562+
filename = 'traffics.sh';
563+
break;
564+
case "har":
565+
filename = 'traffics.har';
566+
break;
567+
};
568+
const file = new File([text], filename, { type: contentType });
569+
570+
const url = URL.createObjectURL(file);
571+
const a = document.createElement('a');
572+
a.href = url;
573+
a.download = filename;
574+
a.click();
575+
576+
URL.revokeObjectURL(url);
577+
})
578+
.catch(err => {
579+
console.error(`Failed to list traffics`, err);
580+
})
581+
}
582+
511583
/**
512584
* @param {CopyKind} kind
513585
*/

src/server.rs

Lines changed: 22 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -110,13 +110,14 @@ impl Server {
110110
self.handle_web_index(&mut res).await
111111
} else if path == "/subscribe/traffics" {
112112
self.handle_subscribe_traffics(&mut res).await
113+
} else if let Some(id) = path.strip_prefix("/subscribe/websocket/") {
114+
self.handle_subscribe_websocket(&mut res, id).await
113115
} else if path == "/traffics" {
114-
self.handle_list_traffics(&mut res).await
116+
let query = req.uri().query().unwrap_or_default();
117+
self.handle_list_traffics(&mut res, query).await
115118
} else if let Some(id) = path.strip_prefix("/traffic/") {
116119
let query = req.uri().query().unwrap_or_default();
117120
self.handle_get_traffic(&mut res, id, query).await
118-
} else if let Some(id) = path.strip_prefix("/subscribe/websocket/") {
119-
self.handle_subscribe_websocket(&mut res, id).await
120121
} else {
121122
*res.status_mut() = StatusCode::NOT_FOUND;
122123
return Ok(res);
@@ -239,7 +240,7 @@ impl Server {
239240
}
240241

241242
async fn handle_subscribe_traffics(&self, res: &mut Response) -> Result<()> {
242-
let (init_data, receiver) = (self.state.list(), self.state.subscribe_traffics());
243+
let (init_data, receiver) = (self.state.list_heads(), self.state.subscribe_traffics());
243244
let stream = BroadcastStream::new(receiver);
244245
let stream = stream
245246
.map_ok(|head| ndjson_frame(&head))
@@ -262,12 +263,19 @@ impl Server {
262263
Ok(())
263264
}
264265

265-
async fn handle_list_traffics(&self, res: &mut Response) -> Result<()> {
266-
set_res_body(res, serde_json::to_string_pretty(&self.state.list())?);
267-
res.headers_mut().insert(
268-
CONTENT_TYPE,
269-
HeaderValue::from_static("application/json; charset=UTF-8"),
270-
);
266+
async fn handle_list_traffics(&self, res: &mut Response, query: &str) -> Result<()> {
267+
if query.is_empty() {
268+
set_res_body(res, serde_json::to_string_pretty(&self.state.list_heads())?);
269+
res.headers_mut().insert(
270+
CONTENT_TYPE,
271+
HeaderValue::from_static("application/json; charset=UTF-8"),
272+
);
273+
} else {
274+
let (data, mime) = self.state.export_traffics(query)?;
275+
set_res_body(res, data);
276+
res.headers_mut()
277+
.insert(CONTENT_TYPE, HeaderValue::from_str(mime)?);
278+
}
271279
res.headers_mut()
272280
.insert(CACHE_CONTROL, HeaderValue::from_static("no-cache"));
273281
Ok(())
@@ -276,21 +284,10 @@ impl Server {
276284
async fn handle_get_traffic(&self, res: &mut Response, id: &str, query: &str) -> Result<()> {
277285
match id.parse().ok().and_then(|id| self.state.get_traffic(id)) {
278286
Some(traffic) => {
279-
match query {
280-
"markdown" | "curl" | "har" | "res-body" => {
281-
let (data, mime) = traffic.export(query)?;
282-
set_res_body(res, data);
283-
res.headers_mut()
284-
.insert(CONTENT_TYPE, HeaderValue::from_str(mime)?);
285-
}
286-
_ => {
287-
set_res_body(res, serde_json::to_string_pretty(&traffic)?);
288-
res.headers_mut().insert(
289-
CONTENT_TYPE,
290-
HeaderValue::from_static("application/json; charset=UTF-8"),
291-
);
292-
}
293-
}
287+
let (data, mime) = traffic.export(query)?;
288+
set_res_body(res, data);
289+
res.headers_mut()
290+
.insert(CONTENT_TYPE, HeaderValue::from_str(mime)?);
294291
res.headers_mut()
295292
.insert(CACHE_CONTROL, HeaderValue::from_static("no-cache"));
296293
}

src/state.rs

Lines changed: 38 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
1-
use crate::traffic::{Body, Traffic};
1+
use crate::traffic::{wrap_entries, Body, Traffic};
22

3+
use anyhow::{anyhow, bail, Result};
34
use indexmap::IndexMap;
45
use serde::Serialize;
6+
use serde_json::Value;
57
use std::sync::Mutex;
68
use time::OffsetDateTime;
79
use tokio::sync::broadcast;
@@ -46,7 +48,7 @@ impl State {
4648
self.traffics_notifier.subscribe()
4749
}
4850

49-
pub(crate) fn list(&self) -> Vec<Head> {
51+
pub(crate) fn list_heads(&self) -> Vec<Head> {
5052
let Ok(entries) = self.traffics.lock() else {
5153
return vec![];
5254
};
@@ -56,6 +58,40 @@ impl State {
5658
.collect()
5759
}
5860

61+
pub(crate) fn export_traffics(&self, format: &str) -> Result<(String, &'static str)> {
62+
let entries = self.traffics.lock().map_err(|err| anyhow!("{err}"))?;
63+
match format {
64+
"markdown" => {
65+
let output = entries
66+
.values()
67+
.map(|v| v.markdown(false))
68+
.collect::<Vec<String>>()
69+
.join("\n\n");
70+
Ok((output, "text/markdown; charset=UTF-8"))
71+
}
72+
"har" => {
73+
let entries: Vec<Value> = entries.values().filter_map(|v| v.har_entry()).collect();
74+
let json_output = wrap_entries(entries);
75+
let output = serde_json::to_string_pretty(&json_output)?;
76+
Ok((output, "application/json; charset=UTF-8"))
77+
}
78+
"curl" => {
79+
let output = entries
80+
.values()
81+
.map(|v| v.curl())
82+
.collect::<Vec<String>>()
83+
.join("\n\n");
84+
Ok((output, "text/plain; charset=UTF-8"))
85+
}
86+
"" => {
87+
let traffics: Vec<&Traffic> = entries.values().collect();
88+
let output = serde_json::to_string_pretty(&traffics)?;
89+
Ok((output, "application/json; charset=UTF-8"))
90+
}
91+
_ => bail!("Unsupported format: {}", format),
92+
}
93+
}
94+
5995
pub(crate) fn new_websocket(&self) -> Option<usize> {
6096
let Ok(mut websockets) = self.websockets.lock() else {
6197
return None;

src/traffic.rs

Lines changed: 54 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,15 @@ impl Traffic {
9292
}
9393

9494
pub fn har(&self) -> Value {
95+
let entries = match self.har_entry() {
96+
Some(v) => vec![v],
97+
None => vec![],
98+
};
99+
wrap_entries(entries)
100+
}
101+
102+
pub fn har_entry(&self) -> Option<Value> {
103+
self.status?;
95104
let request = json!({
96105
"method": self.method,
97106
"url": self.uri,
@@ -103,47 +112,31 @@ impl Traffic {
103112
"headersSize": -1,
104113
"bodySize": -1,
105114
});
106-
let response = match self.status {
107-
Some(status) => json!({
108-
"status": status,
109-
"statusText": "",
110-
"httpVersion": self.res_version,
111-
"cookies": har_res_cookies(&self.res_headers),
112-
"headers": har_headers(&self.res_headers),
113-
"content": har_res_body(&self.res_body, &self.res_headers),
114-
"redirectURL": get_header_value(&self.res_headers, "location").unwrap_or_default(),
115-
"headersSize": -1,
116-
"bodySize": -1,
117-
}),
118-
None => json!({}),
119-
};
120-
json!({
121-
"log": {
122-
"version": "1.2",
123-
"creator": {
124-
"name": "proxyfor",
125-
"version": env!("CARGO_PKG_VERSION"),
126-
"comment": "",
127-
},
128-
"pages": [],
129-
"entries": [
130-
{
131-
"startedDateTime": self.start.format(&Rfc3339).unwrap_or_default(),
132-
"time": -1,
133-
"request": request,
134-
"response": response,
135-
"cache": {},
136-
"timings": {
137-
"connect": -1,
138-
"ssl": -1,
139-
"send": -1,
140-
"receive": -1,
141-
"wait": -1
142-
}
143-
}
144-
]
115+
let response = json!({
116+
"status": self.status.unwrap_or_default(),
117+
"statusText": "",
118+
"httpVersion": self.res_version,
119+
"cookies": har_res_cookies(&self.res_headers),
120+
"headers": har_headers(&self.res_headers),
121+
"content": har_res_body(&self.res_body, &self.res_headers),
122+
"redirectURL": get_header_value(&self.res_headers, "location").unwrap_or_default(),
123+
"headersSize": -1,
124+
"bodySize": -1,
125+
});
126+
Some(json!({
127+
"startedDateTime": self.start.format(&Rfc3339).unwrap_or_default(),
128+
"time": -1,
129+
"request": request,
130+
"response": response,
131+
"cache": {},
132+
"timings": {
133+
"connect": -1,
134+
"ssl": -1,
135+
"send": -1,
136+
"receive": -1,
137+
"wait": -1
145138
}
146-
})
139+
}))
147140
}
148141

149142
pub fn curl(&self) -> String {
@@ -175,15 +168,19 @@ impl Traffic {
175168
output
176169
}
177170

178-
pub fn export<'a>(&'a self, format: &str) -> Result<(String, &'a str)> {
171+
pub fn export(&self, format: &str) -> Result<(String, &'static str)> {
179172
match format {
180173
"markdown" => Ok((self.markdown(false), "text/markdown; charset=UTF-8")),
181174
"har" => Ok((
182175
serde_json::to_string_pretty(&self.har())?,
183176
"application/json; charset=UTF-8",
184177
)),
185178
"curl" => Ok((self.curl(), "text/plain; charset=UTF-8")),
186-
_ => bail!("unsupported format: {}", format),
179+
"" => Ok((
180+
serde_json::to_string_pretty(&self)?,
181+
"application/json; charset=UTF-8",
182+
)),
183+
_ => bail!("Unsupported format: {}", format),
187184
}
188185
}
189186
}
@@ -446,6 +443,21 @@ fn md_lang(content_type: &str) -> &str {
446443
}
447444
}
448445

446+
pub(crate) fn wrap_entries(entries: Vec<Value>) -> Value {
447+
json!({
448+
"log": {
449+
"version": "1.2",
450+
"creator": {
451+
"name": "proxyfor",
452+
"version": env!("CARGO_PKG_VERSION"),
453+
"comment": "",
454+
},
455+
"pages": [],
456+
"entries": entries,
457+
}
458+
})
459+
}
460+
449461
pub(crate) fn serialize_datetime<S>(date: &OffsetDateTime, serializer: S) -> Result<S::Ok, S::Error>
450462
where
451463
S: Serializer,

0 commit comments

Comments
 (0)