use std::time::Duration;
use axum::body::Bytes;
use axum::extract::{Path, Query, State};
use axum::http::{HeaderMap, header};
use axum::response::{IntoResponse, Response};
use serde::Deserialize;
use serde_json::json;
use tokio::sync::{mpsc, watch};
use crate::error::{ApiError, ErrorCode};
use crate::jobs::{JobState, Jobs, StatusPage};
use crate::server::{AppState, PAGE_SIZE};
use crate::types::Document;
pub(crate) const KEEPALIVE: Duration = Duration::from_secs(30);
#[derive(Deserialize)]
pub struct StreamQuery {
#[serde(default)]
pub cursor: Option<u64>,
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
enum Format {
Sse,
Ndjson,
}
impl Format {
fn content_type(self) -> &'static str {
match self {
Format::Sse => "text/event-stream; charset=utf-8",
Format::Ndjson => "application/x-ndjson",
}
}
fn page(self, seq: usize, doc: &Document) -> Bytes {
let data = serde_json::to_string(doc).unwrap_or_else(|_| "null".into());
Bytes::from(match self {
Format::Sse => format!("event: page\nid: {seq}\ndata: {data}\n\n"),
Format::Ndjson => format!("{{\"seq\":{seq},\"type\":\"page\",\"data\":{data}}}\n"),
})
}
fn keepalive(self) -> Bytes {
Bytes::from_static(match self {
Format::Sse => b": keepalive\n\n",
Format::Ndjson => b"{\"type\":\"keepalive\"}\n",
})
}
fn done(self, page: &StatusPage) -> Bytes {
let status = json!({
"status": page.state.as_str(),
"completed": page.completed,
"total": page.total,
});
Bytes::from(match self {
Format::Sse => format!("event: done\ndata: {status}\n\n"),
Format::Ndjson => {
let mut v = json!({"type": "done"});
if let (Some(o), Some(s)) = (v.as_object_mut(), status.as_object()) {
o.extend(s.clone());
}
format!("{v}\n")
}
})
}
}
fn format(headers: &HeaderMap) -> Format {
let accept = headers
.get(header::ACCEPT)
.and_then(|v| v.to_str().ok())
.unwrap_or("");
if accept.contains("application/x-ndjson") {
Format::Ndjson
} else {
Format::Sse
}
}
fn start(q: &StreamQuery, headers: &HeaderMap) -> usize {
let last_event = || {
headers
.get("last-event-id")
.and_then(|v| v.to_str().ok())
.and_then(|v| v.trim().parse::<u64>().ok())
};
q.cursor.or_else(last_event).map_or(0, |c| {
usize::try_from(c).unwrap_or(usize::MAX).saturating_add(1)
})
}
pub async fn job_stream(
State(s): State<AppState>,
headers: HeaderMap,
Path(id): Path<String>,
Query(q): Query<StreamQuery>,
) -> Result<Response, ApiError> {
let changed = s
.jobs
.subscribe(&id)
.await
.ok_or_else(|| ApiError::new(ErrorCode::NotFound, format!("job {id} not found")))?;
let fmt = format(&headers);
let (tx, body) = crate::stream_body::channel();
tokio::spawn(pump(
s.jobs.clone(),
id,
start(&q, &headers),
fmt,
changed,
tx,
KEEPALIVE,
));
Ok((
[
(header::CONTENT_TYPE, fmt.content_type()),
(header::CACHE_CONTROL, "no-cache"),
],
body,
)
.into_response())
}
async fn pump(
jobs: Jobs,
id: String,
mut next: usize,
fmt: Format,
mut changed: watch::Receiver<()>,
tx: mpsc::Sender<Bytes>,
keepalive: Duration,
) {
loop {
let Some(page) = jobs.status(&id, next, PAGE_SIZE).await else {
return;
};
if !page.data.is_empty() {
for doc in &page.data {
if tx.send(fmt.page(next, doc)).await.is_err() {
return;
}
next += 1;
}
continue;
}
if page.state != JobState::Scraping {
let _ = tx.send(fmt.done(&page)).await;
return;
}
tokio::select! {
r = tokio::time::timeout(keepalive, changed.changed()) => {
if r.is_err() && tx.send(fmt.keepalive()).await.is_err() {
return;
}
}
() = tx.closed() => return,
}
}
}
#[cfg(test)]
#[path = "stream_tests.rs"]
mod tests;