parlov-probe 0.3.0

Probe engine trait and HTTP execution layer for parlov.
Documentation
use std::future::Future;
use std::pin::Pin;
use std::task::{Context, Poll};

use bytes::Bytes;
use http::HeaderMap;
use parlov_core::{Error, ProbeDefinition, ProbeExchange, ResponseSurface};
use worker::{Fetch, Headers, Method, Request, RequestInit, Response};

use crate::Probe;

/// Milliseconds-to-nanoseconds conversion factor for `Date.now()` timing.
const NANOS_PER_MS: u64 = 1_000_000;

/// Converts a `Date.now()` millisecond delta to nanoseconds, clamping negatives to zero.
///
/// `Date.now()` has millisecond resolution. The truncation of the fractional part
/// (e.g. 1.9 ms → 1 ms → 1_000_000 ns) is intentional — we do not round.
fn ms_to_ns(elapsed_ms: f64) -> u64 {
    #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
    let ms = elapsed_ms.max(0.0) as u64;
    ms.saturating_mul(NANOS_PER_MS)
}

/// Converts a raw `u16` status code into `http::StatusCode`.
fn parse_status(code: u16) -> Result<http::StatusCode, Error> {
    http::StatusCode::from_u16(code)
        .map_err(|e| Error::Http(format!("unrecognised status code: {e}")))
}

/// Executes HTTP requests via the Cloudflare Workers SDK (`worker::Fetch`).
///
/// Timing uses `Date.now()` with millisecond precision (converted to nanoseconds).
/// No connection pooling — the Workers runtime manages connections.
#[derive(Clone)]
pub struct HttpProbe;

impl HttpProbe {
    /// Creates a new `HttpProbe` for Cloudflare Workers (WASM) environments.
    #[must_use]
    pub fn new() -> Self {
        Self
    }
}

impl Default for HttpProbe {
    fn default() -> Self {
        Self::new()
    }
}

/// Wraps a `!Send` future so it satisfies the `Send` bound on `Probe::execute`.
///
/// `worker::Fetch::send()` internally uses `wasm_bindgen_futures::JsFuture`, which is
/// `!Send`. WASM targets are single-threaded — there is no other thread that could
/// observe the future concurrently. The wrapper satisfies the trait bound without
/// introducing any real cross-thread sharing.
struct SendFuture<F>(F);

// SAFETY: `wasm32-unknown-unknown` is single-threaded. The JS runtime does not provide
// `SharedArrayBuffer`-style threading by default, and Rust's `wasm32` target has no
// `std::thread::spawn`. The inner future will only ever be polled on the single main
// thread, so declaring it `Send` is a no-op in practice.
unsafe impl<F: Future> Send for SendFuture<F> {}

impl<F: Future + Unpin> Future for SendFuture<F> {
    type Output = F::Output;

    fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
        Pin::new(&mut self.0).poll(cx)
    }
}

impl Probe for HttpProbe {
    fn execute(
        &self,
        def: &ProbeDefinition,
    ) -> impl Future<Output = Result<ProbeExchange, Error>> + Send {
        // Clone required: def must be owned to cross the SendFuture boundary.
        let def = def.clone();
        SendFuture(Box::pin(execute_fetch(def)))
    }
}

/// Builds the request, sends it, times the round-trip, and assembles the exchange.
async fn execute_fetch(def: ProbeDefinition) -> Result<ProbeExchange, Error> {
    let request = build_request(&def)?;

    let start_ms = worker::js_sys::Date::now();
    let mut response = Fetch::Request(request)
        .send()
        .await
        .map_err(|e| Error::Http(format!("fetch failed: {e}")))?;
    let elapsed_ms = worker::js_sys::Date::now() - start_ms;

    let surface = extract_surface(&mut response, elapsed_ms).await?;
    Ok(ProbeExchange {
        request: def,
        response: surface,
    })
}

/// Constructs a `worker::Request` from a `ProbeDefinition`.
fn build_request(def: &ProbeDefinition) -> Result<Request, Error> {
    let mut init = RequestInit::new();
    init.with_method(Method::from(def.method.as_str().to_owned()));
    init.with_headers(build_headers(&def.headers)?);

    if let Some(body) = &def.body {
        let arr = worker::js_sys::Uint8Array::from(body.as_ref());
        init.with_body(Some(arr.into()));
    }

    Request::new_with_init(&def.url, &init)
        .map_err(|e| Error::Http(format!("failed to build Request: {e}")))
}

/// Converts an `http::HeaderMap` into `worker::Headers`.
fn build_headers(map: &HeaderMap) -> Result<Headers, Error> {
    let out = Headers::new();
    for (name, value) in map {
        let val_str = value
            .to_str()
            .map_err(|e| Error::Http(format!("non-ASCII header value: {e}")))?;
        out.append(name.as_str(), val_str)
            .map_err(|e| Error::Http(format!("failed to append header: {e}")))?;
    }
    Ok(out)
}

/// Extracts status, headers, and body from a `worker::Response`.
async fn extract_surface(resp: &mut Response, elapsed_ms: f64) -> Result<ResponseSurface, Error> {
    let status = parse_status(resp.status_code())?;
    let headers = parse_response_headers(resp.headers());
    let body_bytes = resp
        .bytes()
        .await
        .map(Bytes::from)
        .map_err(|e| Error::Http(format!("failed to read response body: {e}")))?;

    Ok(ResponseSurface {
        status,
        headers,
        body: body_bytes,
        timing_ns: ms_to_ns(elapsed_ms),
    })
}

/// Converts `worker::Headers` entries into an `http::HeaderMap`.
///
/// Invalid header names or non-ASCII values are silently skipped — the same
/// defensive behaviour as the native `convert_headers` path.
fn parse_response_headers(worker_headers: &Headers) -> HeaderMap {
    let mut out = HeaderMap::new();
    for (name, value) in worker_headers.entries() {
        if let (Ok(n), Ok(v)) = (
            http::header::HeaderName::from_bytes(name.as_bytes()),
            http::header::HeaderValue::from_str(&value),
        ) {
            out.append(n, v);
        }
    }
    out
}

// These tests run only in a wasm32 test harness. Verify via wasm-pack test.
#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn ms_to_ns_converts_correctly() {
        assert_eq!(ms_to_ns(1.0), 1_000_000);
        assert_eq!(ms_to_ns(0.0), 0);
        assert_eq!(ms_to_ns(500.0), 500_000_000);
    }

    #[test]
    fn ms_to_ns_clamps_negative_to_zero() {
        assert_eq!(ms_to_ns(-5.0), 0);
        assert_eq!(ms_to_ns(-0.001), 0);
    }

    #[test]
    fn ms_to_ns_handles_fractional_truncation() {
        // 1.9 ms → 1 ms (truncated) → 1_000_000 ns
        assert_eq!(ms_to_ns(1.9), 1_000_000);
    }

    #[test]
    fn parse_status_200_ok() {
        let status = parse_status(200).expect("valid status");
        assert_eq!(status, http::StatusCode::OK);
    }

    #[test]
    fn parse_status_404_not_found() {
        let status = parse_status(404).expect("valid status");
        assert_eq!(status, http::StatusCode::NOT_FOUND);
    }

    #[test]
    fn parse_status_403_forbidden() {
        let status = parse_status(403).expect("valid status");
        assert_eq!(status, http::StatusCode::FORBIDDEN);
    }

    #[test]
    fn parse_status_rejects_zero() {
        assert!(parse_status(0).is_err());
    }

    #[test]
    fn parse_status_rejects_1000() {
        assert!(parse_status(1000).is_err());
    }
}