use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::Arc;
use anyhow::Result;
use axum::{
body::Body,
extract::Query,
http::{header, HeaderValue, StatusCode, Uri},
response::{IntoResponse, Response},
routing::get,
Json, Router,
};
use rust_embed::RustEmbed;
use serde_json::{json, Value};
use tokio::net::TcpListener;
use crate::config::{state_path, Config};
use crate::ws::ws_upgrade;
#[derive(RustEmbed)]
#[folder = "$OUT_DIR/ui/dist/"]
struct UiAssets;
pub(crate) async fn run_cloudflared(cfg: Config, bind: String) -> Result<()> {
let shared = Arc::new(cfg);
let app = Router::new()
.route("/ws", get(ws_upgrade))
.route("/state", get(get_state).put(put_state))
.route("/history", get(get_history))
.fallback(get(serve_ui_asset))
.with_state(shared);
let listener = TcpListener::bind(&bind).await?;
eprintln!("Mezame is listening on: http://{bind}");
axum::serve(listener, app)
.with_graceful_shutdown(shutdown_signal())
.await?;
Ok(())
}
async fn shutdown_signal() {
let ctrl_c = async {
let _ = tokio::signal::ctrl_c().await;
};
#[cfg(unix)]
let terminate = async {
use tokio::signal::unix::{signal, SignalKind};
match signal(SignalKind::terminate()) {
Ok(mut s) => {
s.recv().await;
}
Err(e) => {
eprintln!("Failed to install SIGTERM handler: {e}");
std::future::pending::<()>().await;
}
}
};
#[cfg(not(unix))]
let terminate = std::future::pending::<()>();
tokio::select! {
_ = ctrl_c => eprintln!("\nReceived SIGINT, shutting down."),
_ = terminate => eprintln!("Received SIGTERM, shutting down."),
}
}
async fn serve_ui_asset(uri: Uri) -> Response {
let raw_path = uri.path().trim_start_matches('/');
let (asset, resolved_path) = match UiAssets::get(raw_path) {
Some(a) => (a, raw_path),
None => match UiAssets::get("index.html") {
Some(a) => (a, "index.html"),
None => {
return (StatusCode::NOT_FOUND, "UI bundle missing").into_response();
}
},
};
let is_index = resolved_path == "index.html";
let mime = mime_for(resolved_path);
let cache_control = if is_index || resolved_path == "sw.js" {
"no-cache, no-store, must-revalidate"
} else if resolved_path.starts_with("assets/") {
"public, max-age=31536000, immutable"
} else {
"public, max-age=3600"
};
Response::builder()
.status(StatusCode::OK)
.header(header::CONTENT_TYPE, HeaderValue::from_static(mime))
.header(
header::CACHE_CONTROL,
HeaderValue::from_static(cache_control),
)
.body(Body::from(asset.data.into_owned()))
.unwrap_or_else(|_| {
(StatusCode::INTERNAL_SERVER_ERROR, "response build failed").into_response()
})
}
fn mime_for(path: &str) -> &'static str {
let lower = path.rsplit('.').next().unwrap_or("").to_ascii_lowercase();
match lower.as_str() {
"html" => "text/html; charset=utf-8",
"js" | "mjs" => "application/javascript; charset=utf-8",
"css" => "text/css; charset=utf-8",
"json" => "application/json; charset=utf-8",
"map" => "application/json; charset=utf-8",
"svg" => "image/svg+xml",
"png" => "image/png",
"jpg" | "jpeg" => "image/jpeg",
"gif" => "image/gif",
"webp" => "image/webp",
"ico" => "image/x-icon",
"woff" => "font/woff",
"woff2" => "font/woff2",
"ttf" => "font/ttf",
"otf" => "font/otf",
"txt" => "text/plain; charset=utf-8",
_ => "application/octet-stream",
}
}
async fn get_state() -> Result<Json<Value>, (StatusCode, String)> {
let path = state_path().map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("{e}")))?;
match tokio::fs::read_to_string(&path).await {
Ok(raw) => {
let v: Value = serde_json::from_str(&raw).unwrap_or_else(|_| json!({}));
Ok(Json(v))
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Json(json!({}))),
Err(e) => Err((StatusCode::INTERNAL_SERVER_ERROR, format!("{e}"))),
}
}
async fn put_state(Json(body): Json<Value>) -> Result<StatusCode, (StatusCode, String)> {
let path = state_path().map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("{e}")))?;
if let Some(parent) = path.parent() {
tokio::fs::create_dir_all(parent)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("{e}")))?;
}
let tmp = path.with_extension("json.tmp");
let data = serde_json::to_string_pretty(&body)
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("{e}")))?;
tokio::fs::write(&tmp, data)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("{e}")))?;
tokio::fs::rename(&tmp, &path)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("{e}")))?;
Ok(StatusCode::NO_CONTENT)
}
async fn get_history(
Query(params): Query<HashMap<String, String>>,
) -> Result<Json<Value>, (StatusCode, String)> {
let Some(sid) = params.get("session") else {
return Err((StatusCode::BAD_REQUEST, "missing ?session=<id>".into()));
};
if sid.is_empty() || sid.contains('/') || sid.contains('\\') || sid.contains("..") {
return Err((StatusCode::BAD_REQUEST, "invalid session id".into()));
}
let Ok(home) = std::env::var("HOME") else {
return Err((StatusCode::INTERNAL_SERVER_ERROR, "HOME not set".into()));
};
let path = PathBuf::from(home).join(format!(".kiro/sessions/cli/{sid}.jsonl"));
let raw = match tokio::fs::read_to_string(&path).await {
Ok(s) => s,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
return Ok(Json(json!({ "entries": [] })));
}
Err(e) => return Err((StatusCode::INTERNAL_SERVER_ERROR, format!("{e}"))),
};
let entries = parse_kiro_history(&raw);
Ok(Json(json!({ "entries": entries })))
}
fn parse_kiro_history(raw: &str) -> Vec<Value> {
let mut out: Vec<Value> = Vec::new();
let mut current_ts_ms: Option<i64> = None;
for line in raw.lines() {
if line.trim().is_empty() {
continue;
}
let Ok(entry) = serde_json::from_str::<Value>(line) else {
continue;
};
let kind = entry.get("kind").and_then(Value::as_str).unwrap_or("");
let data = entry.get("data").cloned().unwrap_or(Value::Null);
match kind {
"Prompt" => {
let ts_sec = data
.get("meta")
.and_then(|m| m.get("timestamp"))
.and_then(Value::as_i64);
if let Some(secs) = ts_sec {
current_ts_ms = Some(secs.saturating_mul(1000));
}
if let Some(text) = extract_text_blocks(&data) {
out.push(json!({
"role": "user",
"text": text,
"timestamp": current_ts_ms
}));
}
}
"AssistantMessage" => {
if let Some(text) = extract_text_blocks(&data) {
out.push(json!({
"role": "agent",
"text": text,
"timestamp": current_ts_ms
}));
}
}
_ => {
}
}
}
out
}
fn extract_text_blocks(data: &Value) -> Option<String> {
let content = data.get("content")?.as_array()?;
let mut buf = String::new();
for block in content {
if block.get("kind").and_then(Value::as_str) == Some("text") {
if let Some(s) = block.get("data").and_then(Value::as_str) {
if !s.is_empty() {
if !buf.is_empty() {
buf.push('\n');
}
buf.push_str(s);
}
}
}
}
if buf.is_empty() {
None
} else {
Some(buf)
}
}