Skip to main content

forge/cmd/test/
evm_profile_server.rs

1//! Local HTTP server for serving EVM profiles to speedscope.app.
2//!
3//! This module implements a temporary local HTTP server that:
4//! 1. Serves the profile JSON at `/{token}/profile.json`
5//! 2. Sets CORS headers to allow the viewer to fetch it
6//! 3. Constructs the proper URL and opens it in the browser
7use axum::{
8    Router,
9    body::Bytes,
10    extract::State,
11    http::{Method, StatusCode, header},
12    response::{IntoResponse, Response},
13    routing::get,
14};
15use eyre::Result;
16use foundry_common::{sh_err, sh_println};
17use tokio::net::TcpListener;
18use tower_http::cors::{Any, CorsLayer};
19
20/// Serves a profile on a local HTTP server and opens it in the browser.
21///
22/// Takes the already-serialized profile JSON bytes.
23/// The server runs until Ctrl+C is pressed.
24pub async fn serve_and_open(
25    profile_json: Vec<u8>,
26    test_name: &str,
27    contract_name: &str,
28) -> Result<()> {
29    let token = generate_token();
30
31    let state = ServerState { profile_json: Bytes::from(profile_json) };
32
33    let app = Router::new()
34        .route(&format!("/{token}/profile.json"), get(serve_profile))
35        .layer(
36            CorsLayer::new()
37                .allow_origin(Any)
38                .allow_methods([Method::GET, Method::OPTIONS])
39                .allow_headers(Any),
40        )
41        .with_state(state);
42
43    let bind_addr = "127.0.0.1:0";
44
45    let listener = TcpListener::bind(bind_addr).await?;
46    let port = listener.local_addr()?.port();
47
48    let profile_url = format!("http://127.0.0.1:{port}/{token}/profile.json");
49    let title = format!("{contract_name}::{test_name}");
50
51    let encoded_url = percent_encode(&profile_url);
52    let encoded_title = percent_encode(&title);
53    let viewer_url =
54        format!("https://www.speedscope.app/#profileURL={encoded_url}&title={encoded_title}");
55
56    sh_println!("Profile server running at http://127.0.0.1:{port}")?;
57
58    sh_println!("Opening speedscope: {viewer_url}")?;
59    if let Err(e) = opener::open(&viewer_url) {
60        sh_err!("Failed to open browser: {e}")?;
61    }
62
63    sh_println!("\nPress Ctrl+C to stop the server.")?;
64
65    // Run the server until interrupted.
66    axum::serve(listener, app).await?;
67
68    Ok(())
69}
70
71/// Generates a random token for the URL path (32 hex characters).
72fn generate_token() -> String {
73    use std::{
74        hash::{DefaultHasher, Hasher},
75        time::{SystemTime, UNIX_EPOCH},
76    };
77
78    let nanos = SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_nanos()).unwrap_or(0);
79    let mut hasher = DefaultHasher::new();
80    hasher.write_u128(nanos);
81    hasher.write_usize(std::process::id() as usize);
82    let random_part = hasher.finish();
83    format!("{nanos:016x}{random_part:016x}")
84}
85
86/// Percent-encode a URL for embedding in viewer URL parameters.
87fn percent_encode(url: &str) -> String {
88    let mut result = String::with_capacity(url.len() * 3);
89    for byte in url.bytes() {
90        match byte {
91            // Unreserved characters (RFC 3986)
92            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' => {
93                result.push(byte as char);
94            }
95            // Everything else gets percent-encoded
96            _ => {
97                result.push('%');
98                result.push_str(&format!("{byte:02X}"));
99            }
100        }
101    }
102    result
103}
104
105#[derive(Clone)]
106struct ServerState {
107    profile_json: Bytes,
108}
109
110async fn serve_profile(State(state): State<ServerState>) -> Response {
111    (StatusCode::OK, [(header::CONTENT_TYPE, "application/json")], state.profile_json)
112        .into_response()
113}