use crate::config::{WebsocketAuth, WebsocketSourceConfig, decode_frame, shape_record};
use async_trait::async_trait;
use base64::Engine;
use faucet_core::{
AuthSpec, Credential, FaucetError, SharedAuthProvider, Source, Stream, StreamPage,
};
use futures::{SinkExt, StreamExt};
use serde_json::Value;
use std::collections::HashMap;
use std::pin::Pin;
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
use tokio::net::TcpStream;
use tokio_tungstenite::tungstenite::client::IntoClientRequest;
use tokio_tungstenite::tungstenite::handshake::client::Request;
use tokio_tungstenite::tungstenite::http::{HeaderName, HeaderValue, header};
use tokio_tungstenite::tungstenite::protocol::frame::coding::CloseCode;
use tokio_tungstenite::tungstenite::protocol::{Message, WebSocketConfig};
use tokio_tungstenite::{MaybeTlsStream, WebSocketStream, connect_async_with_config};
type WsStream = WebSocketStream<MaybeTlsStream<TcpStream>>;
fn now_unix_ms() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_millis() as u64)
.unwrap_or(0)
}
fn credential_to_auth(cred: Credential) -> WebsocketAuth {
use std::collections::BTreeMap;
match cred {
Credential::Bearer(token) => WebsocketAuth::Bearer { token },
Credential::Token(t) => WebsocketAuth::Custom {
headers: BTreeMap::from([("Authorization".to_string(), t)]),
},
Credential::Header { name, value } => WebsocketAuth::Custom {
headers: BTreeMap::from([(name, value)]),
},
Credential::Basic { username, password } => {
let encoded =
base64::engine::general_purpose::STANDARD.encode(format!("{username}:{password}"));
WebsocketAuth::Custom {
headers: BTreeMap::from([(
"Authorization".to_string(),
format!("Basic {encoded}"),
)]),
}
}
}
}
pub(crate) fn apply_auth(request: &mut Request, auth: &WebsocketAuth) -> Result<(), FaucetError> {
let headers = request.headers_mut();
match auth {
WebsocketAuth::None => {}
WebsocketAuth::Bearer { token } => {
let value = HeaderValue::from_str(&format!("Bearer {token}"))
.map_err(|e| FaucetError::Config(format!("websocket bearer header: {e}")))?;
headers.insert(header::AUTHORIZATION, value);
}
WebsocketAuth::Custom { headers: custom } => {
for (k, v) in custom {
let name = HeaderName::from_bytes(k.as_bytes())
.map_err(|e| FaucetError::Config(format!("websocket header name {k}: {e}")))?;
let value = HeaderValue::from_str(v)
.map_err(|e| FaucetError::Config(format!("websocket header value {k}: {e}")))?;
headers.insert(name, value);
}
}
}
Ok(())
}
pub struct WebsocketSource {
config: WebsocketSourceConfig,
auth_provider: Option<SharedAuthProvider>,
}
impl WebsocketSource {
pub fn new(config: WebsocketSourceConfig) -> Result<Self, FaucetError> {
config.validate()?;
Ok(Self {
config,
auth_provider: None,
})
}
pub fn with_auth_provider(mut self, provider: SharedAuthProvider) -> Self {
self.auth_provider = Some(provider);
self
}
async fn connect(&self, url: &str) -> Result<WsStream, FaucetError> {
let mut request = url
.into_client_request()
.map_err(|e| FaucetError::Config(format!("websocket url {url}: {e}")))?;
let effective_auth = if let Some(p) = &self.auth_provider {
credential_to_auth(p.credential().await?)
} else {
match &self.config.auth {
AuthSpec::Inline(a) => a.clone(),
AuthSpec::Reference(r) => {
return Err(FaucetError::Auth(format!(
"auth references provider '{}' but no provider was supplied; \
set one via the CLI `auth:` catalog or `with_auth_provider`",
r.name
)));
}
}
};
apply_auth(&mut request, &effective_auth)?;
let ws_config = self.config.max_message_bytes.map(|n| {
WebSocketConfig::default()
.max_message_size(Some(n))
.max_frame_size(Some(n))
});
let (mut ws, _resp) = connect_async_with_config(request, ws_config, false)
.await
.map_err(|e| FaucetError::Source(format!("websocket connect {url}: {e}")))?;
for msg in &self.config.subscribe_messages {
ws.send(Message::Text(msg.clone().into()))
.await
.map_err(|e| FaucetError::Source(format!("websocket subscribe: {e}")))?;
}
Ok(ws)
}
}
#[async_trait]
impl Source for WebsocketSource {
async fn fetch_with_context(
&self,
context: &HashMap<String, Value>,
) -> Result<Vec<Value>, FaucetError> {
let mut out = Vec::new();
let mut pages = self.stream_pages(context, self.config.batch_size);
while let Some(page) = pages.next().await {
out.extend(page?.records);
}
Ok(out)
}
fn stream_pages<'a>(
&'a self,
context: &'a HashMap<String, Value>,
_batch_size: usize,
) -> Pin<Box<dyn Stream<Item = Result<StreamPage, FaucetError>> + Send + 'a>> {
let resolved_url = faucet_core::util::substitute_context(&self.config.url, context);
let batch_size = self.config.batch_size;
let page_chunk = if batch_size == 0 {
usize::MAX
} else {
batch_size
};
let max_messages = self.config.max_messages.unwrap_or(usize::MAX);
let idle_timeout = self.config.idle_timeout;
let reconnect = self.config.reconnect;
let backoff = self.config.reconnect_backoff;
let max_attempts = self.config.max_reconnect_attempts;
let ping_interval = self.config.ping_interval;
let format = self.config.message_format;
let on_parse_error = self.config.on_parse_error;
let envelope = self.config.envelope;
Box::pin(async_stream::try_stream! {
let mut buffer: Vec<Value> = Vec::new();
let mut total: usize = 0;
let mut last_message_at = Instant::now();
let mut reconnect_attempts: usize = 0;
'outer: loop {
if let Some(t) = idle_timeout
&& Instant::now() >= last_message_at + t
{
tracing::debug!("websocket source: idle_timeout reached, stopping");
break 'outer;
}
let ws = match self.connect(&resolved_url).await {
Ok(ws) => {
reconnect_attempts = 0;
ws
}
Err(e) => {
if reconnect
&& max_attempts.is_none_or(|m| reconnect_attempts < m)
{
reconnect_attempts += 1;
tracing::warn!(error = %e, attempt = reconnect_attempts, "websocket source: connect failed, retrying");
tokio::time::sleep(backoff).await;
continue 'outer;
}
Err(e)?;
break 'outer; }
};
let (mut write, mut read) = ws.split();
let mut ping_timer = ping_interval.map(|interval| {
tokio::time::interval_at(tokio::time::Instant::now() + interval, interval)
});
loop {
let idle_deadline = idle_timeout.map(|t| last_message_at + t);
let poll_budget = match idle_deadline {
Some(d) => d.saturating_duration_since(Instant::now()),
None => Duration::from_secs(3600),
};
let mut stop = false;
let mut fatal: Option<FaucetError> = None;
let mut reconnect_now = false;
let mut handle_payload = |payload: &[u8]| {
last_message_at = Instant::now();
match decode_frame(format, on_parse_error, payload) {
Ok(Some(v)) => {
let now = if envelope { now_unix_ms() } else { 0 };
buffer.push(shape_record(v, envelope, &resolved_url, now));
reconnect_attempts = 0;
total += 1;
if total >= max_messages {
stop = true;
}
}
Ok(None) => {}
Err(e) => fatal = Some(e),
}
};
tokio::select! {
biased;
_ = tokio::signal::ctrl_c() => {
tracing::info!("websocket source: ctrl_c received, stopping cleanly");
stop = true;
}
_ = async { ping_timer.as_mut().unwrap().tick().await }, if ping_timer.is_some() => {
if let Err(e) = write.send(Message::Ping(Vec::new().into())).await {
tracing::warn!(error = %e, "websocket source: ping failed, treating as disconnect");
reconnect_now = true;
}
}
recv = tokio::time::timeout(poll_budget, read.next()) => {
match recv {
Ok(Some(Ok(msg))) => {
match msg {
Message::Text(t) => handle_payload(t.as_bytes()),
Message::Binary(b) => handle_payload(&b),
Message::Ping(payload) => {
if let Err(e) = write.send(Message::Pong(payload)).await {
tracing::warn!(error = %e, "websocket source: pong failed");
reconnect_now = true;
}
}
Message::Pong(_) | Message::Frame(_) => {}
Message::Close(frame) => {
let clean = frame
.as_ref()
.map(|f| f.code == CloseCode::Normal)
.unwrap_or(true);
if clean && !reconnect {
tracing::info!("websocket source: server closed (1000), stopping");
stop = true;
} else {
tracing::warn!(?frame, "websocket source: connection closed");
reconnect_now = true;
}
}
}
}
Ok(Some(Err(e))) => {
tracing::warn!(error = %e, "websocket source: read error");
reconnect_now = true;
}
Ok(None) => {
tracing::warn!("websocket source: stream ended");
reconnect_now = true;
}
Err(_elapsed) => {
if let Some(d) = idle_deadline
&& Instant::now() >= d
{
tracing::debug!("websocket source: idle_timeout reached, stopping");
stop = true;
}
}
}
}
}
if let Some(e) = fatal {
Err(e)?;
}
if !buffer.is_empty() && buffer.len() >= page_chunk {
let page = std::mem::take(&mut buffer);
yield StreamPage { records: page, bookmark: None };
}
if stop {
break 'outer;
}
if reconnect_now {
if reconnect && max_attempts.is_none_or(|m| reconnect_attempts < m) {
reconnect_attempts += 1;
tracing::warn!(attempt = reconnect_attempts, "websocket source: reconnecting");
tokio::time::sleep(backoff).await;
continue 'outer;
} else if reconnect {
Err(FaucetError::Source(format!(
"websocket source: exceeded max_reconnect_attempts ({})",
max_attempts.unwrap_or(0)
)))?;
} else {
Err(FaucetError::Source(
"websocket source: connection closed and reconnect=false".into(),
))?;
}
}
}
}
if !buffer.is_empty() {
yield StreamPage { records: buffer, bookmark: None };
}
tracing::info!(messages = total, "websocket source: stream complete");
})
}
fn config_schema(&self) -> Value {
let schema = schemars::schema_for!(WebsocketSourceConfig);
serde_json::to_value(&schema).unwrap_or(Value::Null)
}
fn connector_name(&self) -> &'static str {
"websocket"
}
fn dataset_uri(&self) -> String {
faucet_core::redact_uri_credentials(&self.config.url)
}
async fn check(
&self,
ctx: &faucet_core::check::CheckContext,
) -> Result<faucet_core::check::CheckReport, FaucetError> {
use faucet_core::check::{CheckReport, Probe};
let start = std::time::Instant::now();
let (host, port) = match resolve_host_port(&self.config.url) {
Ok(hp) => hp,
Err(reason) => {
return Ok(CheckReport::single(Probe::fail_hint(
"network",
start.elapsed(),
reason,
"url must be ws://host[:port]/... or wss://host[:port]/...",
)));
}
};
let connect = tokio::net::TcpStream::connect((host.as_str(), port));
match tokio::time::timeout(ctx.timeout, connect).await {
Ok(Ok(stream)) => {
drop(stream);
Ok(CheckReport::single(Probe::pass("network", start.elapsed())))
}
Ok(Err(e)) => Ok(CheckReport::single(Probe::fail_hint(
"network",
start.elapsed(),
e.to_string(),
format!("cannot reach {host}:{port} over TCP"),
))),
Err(_elapsed) => Ok(CheckReport::single(Probe::fail_hint(
"network",
start.elapsed(),
format!("TCP connect to {host}:{port} timed out"),
format!("{host}:{port} did not accept a connection within the check timeout"),
))),
}
}
}
fn resolve_host_port(url: &str) -> Result<(String, u16), String> {
let request = url
.into_client_request()
.map_err(|e| format!("invalid websocket url: {e}"))?;
let uri = request.uri();
let host = uri
.host()
.filter(|h| !h.is_empty())
.ok_or_else(|| "websocket url is missing a host".to_string())?
.to_string();
let default_port = match uri.scheme_str() {
Some("wss") => 443,
_ => 80,
};
let port = uri.port_u16().unwrap_or(default_port);
Ok((host, port))
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::BTreeMap;
#[test]
fn credential_bearer_maps_to_bearer() {
let auth = credential_to_auth(Credential::Bearer("tok".into()));
assert_eq!(
auth,
WebsocketAuth::Bearer {
token: "tok".into()
}
);
}
#[test]
fn credential_token_maps_to_custom_authorization() {
let auth = credential_to_auth(Credential::Token("Custom xyz".into()));
assert_eq!(
auth,
WebsocketAuth::Custom {
headers: BTreeMap::from([("Authorization".into(), "Custom xyz".into())])
}
);
}
#[test]
fn credential_header_maps_to_custom() {
let auth = credential_to_auth(Credential::Header {
name: "X-Api-Key".into(),
value: "k123".into(),
});
assert_eq!(
auth,
WebsocketAuth::Custom {
headers: BTreeMap::from([("X-Api-Key".into(), "k123".into())])
}
);
}
#[test]
fn credential_basic_maps_to_base64_authorization() {
let auth = credential_to_auth(Credential::Basic {
username: "user".into(),
password: "pass".into(),
});
assert_eq!(
auth,
WebsocketAuth::Custom {
headers: BTreeMap::from([("Authorization".into(), "Basic dXNlcjpwYXNz".into())])
}
);
}
#[test]
fn resolve_host_port_applies_scheme_defaults() {
assert_eq!(
resolve_host_port("ws://example.com/feed").unwrap(),
("example.com".to_string(), 80)
);
assert_eq!(
resolve_host_port("wss://example.com/feed").unwrap(),
("example.com".to_string(), 443)
);
assert_eq!(
resolve_host_port("wss://example.com:9443/feed").unwrap(),
("example.com".to_string(), 9443)
);
}
#[tokio::test]
async fn check_passes_against_a_live_tcp_listener() {
use faucet_core::check::{CheckContext, ProbeStatus};
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let config = WebsocketSourceConfig {
url: format!("ws://{addr}/feed"),
auth: AuthSpec::Inline(WebsocketAuth::None),
subscribe_messages: vec![],
message_format: crate::config::WsMessageFormat::Json,
on_parse_error: crate::config::OnParseError::Fail,
envelope: false,
ping_interval: None,
max_messages: Some(1),
idle_timeout: None,
reconnect: false,
reconnect_backoff: Duration::from_secs(1),
max_reconnect_attempts: None,
max_message_bytes: None,
batch_size: faucet_core::DEFAULT_BATCH_SIZE,
};
let source = WebsocketSource::new(config).unwrap();
let report = source.check(&CheckContext::default()).await.unwrap();
assert_eq!(report.probes.len(), 1);
assert_eq!(report.probes[0].name, "network");
assert!(
matches!(report.probes[0].status, ProbeStatus::Pass),
"expected Pass, got {:?}",
report.probes[0].status
);
}
#[tokio::test]
async fn check_fails_against_a_closed_port() {
use faucet_core::check::{CheckContext, ProbeStatus};
let addr = {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
listener.local_addr().unwrap()
};
let config = WebsocketSourceConfig {
url: format!("ws://{addr}/feed"),
auth: AuthSpec::Inline(WebsocketAuth::None),
subscribe_messages: vec![],
message_format: crate::config::WsMessageFormat::Json,
on_parse_error: crate::config::OnParseError::Fail,
envelope: false,
ping_interval: None,
max_messages: Some(1),
idle_timeout: None,
reconnect: false,
reconnect_backoff: Duration::from_secs(1),
max_reconnect_attempts: None,
max_message_bytes: None,
batch_size: faucet_core::DEFAULT_BATCH_SIZE,
};
let source = WebsocketSource::new(config).unwrap();
let report = source
.check(&CheckContext {
timeout: Duration::from_secs(2),
})
.await
.unwrap();
assert_eq!(report.probes.len(), 1);
assert_eq!(report.probes[0].name, "network");
assert!(
matches!(report.probes[0].status, ProbeStatus::Fail { .. }),
"expected Fail, got {:?}",
report.probes[0].status
);
assert_eq!(report.failed_count(), 1);
}
fn make_config(url: &str) -> WebsocketSourceConfig {
WebsocketSourceConfig {
url: url.into(),
auth: faucet_core::AuthSpec::Inline(crate::config::WebsocketAuth::None),
subscribe_messages: vec![],
message_format: crate::config::WsMessageFormat::Json,
on_parse_error: crate::config::OnParseError::Fail,
envelope: false,
ping_interval: None,
max_messages: Some(1),
idle_timeout: None,
reconnect: false,
reconnect_backoff: Duration::from_secs(1),
max_reconnect_attempts: None,
max_message_bytes: None,
batch_size: faucet_core::DEFAULT_BATCH_SIZE,
}
}
#[test]
fn dataset_uri_returns_url() {
use faucet_core::Source;
let source = WebsocketSource::new(make_config("wss://stream.example.com/feed")).unwrap();
assert_eq!(source.dataset_uri(), "wss://stream.example.com/feed");
}
#[test]
fn dataset_uri_redacts_credentials() {
use faucet_core::Source;
let source =
WebsocketSource::new(make_config("wss://user:pass@stream.example.com/feed")).unwrap();
assert_eq!(source.dataset_uri(), "wss://stream.example.com/feed");
}
}