crates/dwarves-web-client/src/lib.rs
//! **dwarves-web-client** — the grpc-web (browser) client for a running
//! `dwarves-server`'s `dwarf.v1` forge service.
//!
//! ## Reuse (no twin): same client, only the transport differs
//!
//! The generated [`pb`] module is produced from the SAME proto file the native embed
//! client (`dwarves-embed`) uses (`crates/dwarves-embed/proto/dwarf.proto`, wired in
//! `build.rs`), so the [`DwarfClient`](pb::dwarf_client::DwarfClient) and every message
//! type are byte-identical to the native client's. The ONLY difference is the transport:
//!
//! - **native** (`dwarves-embed`): a tonic [`Channel`] over HTTP/2.
//! - **wasm** (this crate): [`tonic_web_wasm_client::Client`] — grpc-web over the browser
//! `fetch` API — because a browser cannot open a raw HTTP/2 gRPC socket.
//!
//! The `dwarves-server` answers BOTH on the SAME port when built with its `grpc-web`
//! feature (content-negotiated), which is exactly what `dwarves-server`'s `embed-all` turns on.
//!
//! ## Auth
//!
//! Every RPC carries `authorization: Bearer <token>` in its grpc metadata — the same
//! bearer the shared `nornir-service` interceptor enforces server-side. The one-line stamp
//! is inlined here (rather than reusing `nornir_service::attach_bearer`) because
//! `nornir-service` links tonic's native `transport` feature and does not compile to wasm;
//! this is documented, not a silent twin.
//!
//! ## Config (browser)
//!
//! The caller passes a base URL (e.g. `https://cicd.g11.se:7900`) and the bearer token —
//! `dwarves-gui` reads both from the page's `?server=…&token=…` query params, so nothing
//! is hardcoded and no secret is baked into the bundle.
/// The generated `dwarf.v1` client + wire types — identical to `dwarves_embed::pb` (same
/// proto file). `History`/`GetRunReport`/`AgentPool`/`ResolveHolgerBuild` + their messages.
pub mod pb {
tonic::include_proto!("dwarf.v1");
}
#[cfg(target_arch = "wasm32")]
mod web {
use super::pb::dwarf_client::DwarfClient;
use super::pb::{HistoryReply, HistoryRequest, RunReport, RunStatusRequest};
use tonic_web_wasm_client::Client;
/// The grpc-web `DwarfClient` bound to a browser `fetch` transport.
pub type WebDwarfClient = DwarfClient<Client>;
/// Connect a grpc-web client to `base_url` (e.g. `https://cicd.g11.se:7900`). No socket
/// is opened until the first RPC — the transport rides the browser `fetch` API.
pub fn connect(base_url: &str) -> WebDwarfClient {
let base = base_url.trim_end_matches('/').to_string();
DwarfClient::new(Client::new(base))
}
/// Stamp `authorization: Bearer <token>` onto a request (skipped when `token` is empty,
/// which matches the server's open/dev mode). The wasm twin of the native
/// `dwarves::thin_client::attach_bearer` — see the module docs for why it is inlined.
fn bearer<T>(mut req: tonic::Request<T>, token: &str) -> tonic::Request<T> {
if !token.is_empty() {
if let Ok(value) = format!("Bearer {token}").parse() {
req.metadata_mut().insert("authorization", value);
}
}
req
}
/// Fetch the recent-run roster over grpc-web (`Dwarf.History`, newest-first, capped at
/// `limit`). This is what the wasm forge view calls to replace its fixture roster with
/// LIVE runs from the server.
pub async fn fetch_history(
base_url: &str,
token: &str,
limit: u32,
) -> Result<HistoryReply, tonic::Status> {
let mut client = connect(base_url);
let req = bearer(
tonic::Request::new(HistoryRequest {
limit,
pipeline: String::new(),
}),
token,
);
Ok(client.history(req).await?.into_inner())
}
/// Fetch one run's full report over grpc-web (`Dwarf.GetRunReport`) — the parent
/// `RunRow` + a `BuildRow` per step with its `needs` DAG edges. Lets the live view
/// reconstruct the tree + DAG of a selected run (parity with the native embed pane).
pub async fn fetch_run_report(
base_url: &str,
token: &str,
run_id: &str,
) -> Result<RunReport, tonic::Status> {
let mut client = connect(base_url);
let req = bearer(
tonic::Request::new(RunStatusRequest {
run_id: run_id.to_string(),
}),
token,
);
Ok(client.get_run_report(req).await?.into_inner())
}
}
#[cfg(target_arch = "wasm32")]
pub use web::{connect, fetch_history, fetch_run_report, WebDwarfClient};
#[cfg(test)]
mod tests {
use super::pb;
/// The generated message set is reachable and matches the shared proto (RED-when-broken
/// if the proto path in `build.rs` breaks or the codegen is dropped): construct the wire
/// types the client sends/receives.
#[test]
fn generated_dwarf_v1_types_are_reachable() {
let req = pb::HistoryRequest {
limit: 50,
pipeline: String::new(),
};
assert_eq!(req.limit, 50);
let row = pb::RunRow {
run_id: "#1".into(),
pipeline: "build".into(),
project: "dwarves".into(),
trigger: "push".into(),
status: "passed".into(),
steps: 4,
passed: 4,
duration_ms: 1000,
finished_at: String::new(),
};
assert_eq!(row.passed, 4);
// The full report shape the live view reconstructs a tree/DAG from.
let report = pb::RunReport {
run: Some(row),
steps: Vec::new(),
release_json: String::new(),
};
assert!(report.run.is_some());
}
}