Skip to main content

murmur_wasm/
lib.rs

1//! Murmur WASM - WebAssembly probe runtime.
2//!
3//! This crate provides browser-side probes that can measure network timing
4//! from within a web page. It uses the Fetch API and Performance APIs to
5//! capture timing data.
6//!
7//! ## Capabilities
8//!
9//! In WASM, we can:
10//! - Make HTTP/HTTPS requests via Fetch API
11//! - Measure request timing (DNS, TCP, TLS, TTFB)
12//! - Access Navigation Timing and Resource Timing APIs
13//! - Read network connection information
14//!
15//! We cannot:
16//! - Make raw TCP/UDP connections
17//! - Send ICMP packets (ping/traceroute)
18//! - Access low-level network interfaces
19//!
20//! ## Usage
21//!
22//! ```javascript
23//! import init, { probe_url, get_timing } from 'murmur-wasm';
24//!
25//! await init();
26//! const result = await probe_url('https://example.com');
27//! console.log(result);
28//! ```
29
30use serde::{Deserialize, Serialize};
31use wasm_bindgen::prelude::*;
32use wasm_bindgen_futures::JsFuture;
33use web_sys::{Request, RequestInit, RequestMode, Response};
34
35/// Probe result from a WASM fetch.
36#[derive(Debug, Clone, Serialize, Deserialize)]
37pub struct WasmProbeResult {
38    /// Target URL.
39    pub url: String,
40    /// Whether the probe succeeded.
41    pub success: bool,
42    /// HTTP status code (if request completed).
43    pub status: Option<u16>,
44    /// Error message (if failed).
45    pub error: Option<String>,
46    /// Total duration in milliseconds.
47    pub total_ms: f64,
48    /// DNS lookup time in milliseconds (if available from Resource Timing).
49    pub dns_ms: Option<f64>,
50    /// TCP connection time in milliseconds.
51    pub tcp_ms: Option<f64>,
52    /// TLS handshake time in milliseconds.
53    pub tls_ms: Option<f64>,
54    /// Time to First Byte in milliseconds.
55    pub ttfb_ms: Option<f64>,
56    /// Response size in bytes.
57    pub size_bytes: Option<u64>,
58    /// Protocol used (e.g., "h2", "http/1.1").
59    pub protocol: Option<String>,
60}
61
62/// Probe a URL using the Fetch API.
63#[wasm_bindgen]
64pub async fn probe_url(url: &str) -> JsValue {
65    let result = probe_url_internal(url).await;
66    serde_wasm_bindgen::to_value(&result).unwrap_or(JsValue::NULL)
67}
68
69async fn probe_url_internal(url: &str) -> WasmProbeResult {
70    let start = now();
71
72    let opts = RequestInit::new();
73    opts.set_method("GET");
74    opts.set_mode(RequestMode::Cors);
75
76    let request = match Request::new_with_str_and_init(url, &opts) {
77        Ok(req) => req,
78        Err(e) => {
79            return WasmProbeResult {
80                url: url.to_string(),
81                success: false,
82                status: None,
83                error: Some(format!("failed to create request: {:?}", e)),
84                total_ms: now() - start,
85                dns_ms: None,
86                tcp_ms: None,
87                tls_ms: None,
88                ttfb_ms: None,
89                size_bytes: None,
90                protocol: None,
91            };
92        }
93    };
94
95    let window = match web_sys::window() {
96        Some(w) => w,
97        None => {
98            return WasmProbeResult {
99                url: url.to_string(),
100                success: false,
101                status: None,
102                error: Some("no window object available".to_string()),
103                total_ms: now() - start,
104                dns_ms: None,
105                tcp_ms: None,
106                tls_ms: None,
107                ttfb_ms: None,
108                size_bytes: None,
109                protocol: None,
110            };
111        }
112    };
113
114    let resp_value = match JsFuture::from(window.fetch_with_request(&request)).await {
115        Ok(v) => v,
116        Err(e) => {
117            return WasmProbeResult {
118                url: url.to_string(),
119                success: false,
120                status: None,
121                error: Some(format!("fetch failed: {:?}", e)),
122                total_ms: now() - start,
123                dns_ms: None,
124                tcp_ms: None,
125                tls_ms: None,
126                ttfb_ms: None,
127                size_bytes: None,
128                protocol: None,
129            };
130        }
131    };
132
133    let response: Response = match resp_value.dyn_into() {
134        Ok(r) => r,
135        Err(_) => {
136            return WasmProbeResult {
137                url: url.to_string(),
138                success: false,
139                status: None,
140                error: Some("fetch returned non-Response value".to_string()),
141                total_ms: now() - start,
142                dns_ms: None,
143                tcp_ms: None,
144                tls_ms: None,
145                ttfb_ms: None,
146                size_bytes: None,
147                protocol: None,
148            };
149        }
150    };
151
152    let status = response.status();
153    let total_ms = now() - start;
154
155    // Try to get timing from Resource Timing API
156    let timing = get_resource_timing(url);
157
158    WasmProbeResult {
159        url: url.to_string(),
160        success: (200..400).contains(&status),
161        status: Some(status),
162        error: None,
163        total_ms,
164        dns_ms: timing.as_ref().and_then(|t| t.dns_ms),
165        tcp_ms: timing.as_ref().and_then(|t| t.tcp_ms),
166        tls_ms: timing.as_ref().and_then(|t| t.tls_ms),
167        ttfb_ms: timing.as_ref().and_then(|t| t.ttfb_ms),
168        size_bytes: timing.as_ref().and_then(|t| t.size_bytes),
169        protocol: timing.and_then(|t| t.protocol),
170    }
171}
172
173/// Resource timing data extracted from Performance API.
174struct ResourceTimingData {
175    dns_ms: Option<f64>,
176    tcp_ms: Option<f64>,
177    tls_ms: Option<f64>,
178    ttfb_ms: Option<f64>,
179    size_bytes: Option<u64>,
180    protocol: Option<String>,
181}
182
183/// Get resource timing for a URL from the Performance API.
184fn get_resource_timing(url: &str) -> Option<ResourceTimingData> {
185    let window = web_sys::window()?;
186    let performance = window.performance()?;
187    let entries = performance.get_entries_by_name(url);
188
189    if entries.length() == 0 {
190        return None;
191    }
192
193    // Get the most recent entry
194    let entry = entries.get(entries.length() - 1);
195    let resource: web_sys::PerformanceResourceTiming = entry.dyn_into().ok()?;
196
197    let dns_start = resource.domain_lookup_start();
198    let dns_end = resource.domain_lookup_end();
199    let connect_start = resource.connect_start();
200    let connect_end = resource.connect_end();
201    let secure_start = resource.secure_connection_start();
202    let response_start = resource.response_start();
203
204    let dns_ms = if dns_end > dns_start {
205        Some(dns_end - dns_start)
206    } else {
207        None
208    };
209
210    let tcp_ms = if secure_start > 0.0 && secure_start > connect_start {
211        Some(secure_start - connect_start)
212    } else if connect_end > connect_start {
213        Some(connect_end - connect_start)
214    } else {
215        None
216    };
217
218    let tls_ms = if secure_start > 0.0 && connect_end > secure_start {
219        Some(connect_end - secure_start)
220    } else {
221        None
222    };
223
224    let ttfb_ms = if response_start > 0.0 {
225        Some(response_start)
226    } else {
227        None
228    };
229
230    let size_bytes = {
231        let size = resource.transfer_size();
232        if size > 0.0 {
233            Some(size as u64)
234        } else {
235            None
236        }
237    };
238
239    let protocol = {
240        let proto = resource.next_hop_protocol();
241        if proto.is_empty() {
242            None
243        } else {
244            Some(proto)
245        }
246    };
247
248    Some(ResourceTimingData {
249        dns_ms,
250        tcp_ms,
251        tls_ms,
252        ttfb_ms,
253        size_bytes,
254        protocol,
255    })
256}
257
258/// Get navigation timing data for the current page.
259#[wasm_bindgen]
260pub fn get_navigation_timing() -> JsValue {
261    let timing = get_navigation_timing_internal();
262    serde_wasm_bindgen::to_value(&timing).unwrap_or(JsValue::NULL)
263}
264
265/// Navigation timing data.
266#[derive(Debug, Clone, Serialize, Deserialize)]
267pub struct WasmNavigationTiming {
268    /// Page URL.
269    pub url: String,
270    /// DNS lookup time in milliseconds.
271    pub dns_ms: f64,
272    /// TCP connection time in milliseconds.
273    pub tcp_ms: f64,
274    /// TLS handshake time in milliseconds.
275    pub tls_ms: f64,
276    /// Time to First Byte in milliseconds.
277    pub ttfb_ms: f64,
278    /// DOM Content Loaded time in milliseconds.
279    pub dom_content_loaded_ms: f64,
280    /// Page load time in milliseconds.
281    pub load_ms: f64,
282}
283
284fn get_navigation_timing_internal() -> Option<WasmNavigationTiming> {
285    let window = web_sys::window()?;
286    let performance = window.performance()?;
287    let entries = performance.get_entries_by_type("navigation");
288
289    if entries.length() == 0 {
290        return None;
291    }
292
293    let entry = entries.get(0);
294    let nav: web_sys::PerformanceNavigationTiming = entry.dyn_into().ok()?;
295
296    let dns_start = nav.domain_lookup_start();
297    let dns_end = nav.domain_lookup_end();
298    let connect_start = nav.connect_start();
299    let connect_end = nav.connect_end();
300    let secure_start = nav.secure_connection_start();
301    let response_start = nav.response_start();
302    let dom_content_loaded = nav.dom_content_loaded_event_end();
303    let load_end = nav.load_event_end();
304
305    let dns_ms = (dns_end - dns_start).max(0.0);
306    let tcp_ms = if secure_start > 0.0 {
307        (secure_start - connect_start).max(0.0)
308    } else {
309        (connect_end - connect_start).max(0.0)
310    };
311    let tls_ms = if secure_start > 0.0 {
312        (connect_end - secure_start).max(0.0)
313    } else {
314        0.0
315    };
316    let ttfb_ms = response_start.max(0.0);
317    let dom_content_loaded_ms = dom_content_loaded.max(0.0);
318    let load_ms = load_end.max(0.0);
319
320    Some(WasmNavigationTiming {
321        url: window.location().href().ok()?,
322        dns_ms,
323        tcp_ms,
324        tls_ms,
325        ttfb_ms,
326        dom_content_loaded_ms,
327        load_ms,
328    })
329}
330
331/// Get all resource timing entries.
332#[wasm_bindgen]
333pub fn get_resource_timings() -> JsValue {
334    let timings = get_resource_timings_internal();
335    serde_wasm_bindgen::to_value(&timings).unwrap_or(JsValue::NULL)
336}
337
338/// Resource timing entry.
339#[derive(Debug, Clone, Serialize, Deserialize)]
340pub struct WasmResourceTiming {
341    /// Resource URL.
342    pub url: String,
343    /// Initiator type.
344    pub initiator_type: String,
345    /// Duration in milliseconds.
346    pub duration_ms: f64,
347    /// Transfer size in bytes.
348    pub size_bytes: u64,
349    /// Whether it was cached.
350    pub from_cache: bool,
351    /// Protocol used.
352    pub protocol: Option<String>,
353}
354
355fn get_resource_timings_internal() -> Vec<WasmResourceTiming> {
356    let Some(window) = web_sys::window() else {
357        return Vec::new();
358    };
359    let Some(performance) = window.performance() else {
360        return Vec::new();
361    };
362
363    let entries = performance.get_entries_by_type("resource");
364    let mut results = Vec::with_capacity(entries.length() as usize);
365
366    for i in 0..entries.length() {
367        let entry = entries.get(i);
368        if let Ok(resource) = entry.dyn_into::<web_sys::PerformanceResourceTiming>() {
369            let transfer_size = resource.transfer_size() as u64;
370            let decoded_size = resource.decoded_body_size() as u64;
371
372            results.push(WasmResourceTiming {
373                url: resource.name(),
374                initiator_type: resource.initiator_type(),
375                duration_ms: resource.duration(),
376                size_bytes: transfer_size,
377                from_cache: transfer_size == 0 && decoded_size > 0,
378                protocol: {
379                    let p = resource.next_hop_protocol();
380                    if p.is_empty() {
381                        None
382                    } else {
383                        Some(p)
384                    }
385                },
386            });
387        }
388    }
389
390    results
391}
392
393// JavaScript bindings for Network Information API (not in web-sys)
394#[wasm_bindgen]
395extern "C" {
396    #[wasm_bindgen(thread_local_v2, js_namespace = navigator, js_name = connection)]
397    static CONNECTION: JsValue;
398
399    #[wasm_bindgen(catch, js_namespace = ["navigator", "connection"], js_name = type)]
400    fn connection_type() -> Result<JsValue, JsValue>;
401
402    #[wasm_bindgen(catch, js_namespace = ["navigator", "connection"], js_name = effectiveType)]
403    fn effective_type() -> Result<JsValue, JsValue>;
404
405    #[wasm_bindgen(catch, js_namespace = ["navigator", "connection"], js_name = rtt)]
406    fn connection_rtt() -> Result<JsValue, JsValue>;
407
408    #[wasm_bindgen(catch, js_namespace = ["navigator", "connection"], js_name = downlink)]
409    fn connection_downlink() -> Result<JsValue, JsValue>;
410
411    #[wasm_bindgen(catch, js_namespace = ["navigator", "connection"], js_name = saveData)]
412    fn connection_save_data() -> Result<JsValue, JsValue>;
413}
414
415/// Get network connection information.
416///
417/// Uses the Network Information API when available (Chrome, Edge, Opera).
418/// Returns empty values on unsupported browsers (Firefox, Safari).
419#[wasm_bindgen]
420pub fn get_connection_info() -> JsValue {
421    let info = get_connection_info_internal();
422    serde_wasm_bindgen::to_value(&info).unwrap_or(JsValue::NULL)
423}
424
425/// Network connection information.
426#[derive(Debug, Clone, Serialize, Deserialize)]
427pub struct WasmConnectionInfo {
428    /// Connection type (e.g., "wifi", "cellular", "ethernet").
429    pub connection_type: Option<String>,
430    /// Effective connection type (e.g., "4g", "3g", "2g", "slow-2g").
431    pub effective_type: Option<String>,
432    /// Estimated round-trip time in milliseconds.
433    pub rtt_ms: Option<u32>,
434    /// Estimated downlink speed in Mbps.
435    pub downlink_mbps: Option<f64>,
436    /// Whether data saver is enabled.
437    pub save_data: bool,
438    /// Whether the API is supported.
439    pub api_supported: bool,
440}
441
442fn get_connection_info_internal() -> WasmConnectionInfo {
443    // Check if Network Information API is available
444    let conn = CONNECTION.with(JsValue::clone);
445    if conn.is_undefined() || conn.is_null() {
446        return WasmConnectionInfo {
447            connection_type: None,
448            effective_type: None,
449            rtt_ms: None,
450            downlink_mbps: None,
451            save_data: false,
452            api_supported: false,
453        };
454    }
455
456    let connection_type = connection_type().ok().and_then(|v| v.as_string());
457
458    let effective_type = effective_type().ok().and_then(|v| v.as_string());
459
460    let rtt_ms = connection_rtt()
461        .ok()
462        .and_then(|v| v.as_f64())
463        .map(|v| v as u32);
464
465    let downlink_mbps = connection_downlink().ok().and_then(|v| v.as_f64());
466
467    let save_data = connection_save_data()
468        .ok()
469        .and_then(|v| v.as_bool())
470        .unwrap_or(false);
471
472    WasmConnectionInfo {
473        connection_type,
474        effective_type,
475        rtt_ms,
476        downlink_mbps,
477        save_data,
478        api_supported: true,
479    }
480}
481
482/// Get high-resolution timestamp.
483fn now() -> f64 {
484    web_sys::window()
485        .and_then(|w| w.performance())
486        .map(|p| p.now())
487        .unwrap_or(0.0)
488}
489
490/// Console log helper for debugging.
491#[wasm_bindgen]
492pub fn log(s: &str) {
493    web_sys::console::log_1(&s.into());
494}