Initial CodeCraft: web-based coding harness for AI models
Rust backend (axum/tokio): agent loop with streaming, tools (fs, bash, web_fetch, todo, ask_user, subagents, skills, craft snapshots/compare), permissions with modes and rules, Anthropic + OpenAI-compatible providers, MCP client (stdio/HTTP, elicitation), SQLite sessions, usage statistics, compaction, commented TOML config editable via toml_edit. React PWA: chat with live streaming, permission/question cards, modes (build/plan/craft), settings for providers/models/prices/MCP/permissions, raw config editor, statistics dashboard, i18n (en/de). Docker setup. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
f3a2fe3816
62 files changed
+21835
No files matched your search
@@ -0,0 +1,524 @@
|
||||
//! HTTP + WebSocket API.
|
||||
|
||||
use crate::app::{App, PermissionReply};
|
||||
use crate::config::{self, ConfigPatch};
|
||||
use crate::prompts;
|
||||
use crate::runner;
|
||||
use crate::types::{Event, ImageData, Part, Session, new_id, now_ms};
|
||||
use axum::extract::ws::{Message as WsMessage, WebSocket, WebSocketUpgrade};
|
||||
use axum::extract::{Path, Query, Request, State};
|
||||
use axum::http::{StatusCode, header};
|
||||
use axum::middleware::{self, Next};
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use axum::routing::{get, post};
|
||||
use axum::{Json, Router};
|
||||
use serde::Deserialize;
|
||||
use serde_json::{Map, Value, json};
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
type AppState = Arc<App>;
|
||||
|
||||
pub struct ApiError(StatusCode, String);
|
||||
|
||||
impl IntoResponse for ApiError {
|
||||
fn into_response(self) -> Response {
|
||||
(self.0, Json(json!({"error": self.1}))).into_response()
|
||||
}
|
||||
}
|
||||
|
||||
impl<E: Into<anyhow::Error>> From<E> for ApiError {
|
||||
fn from(e: E) -> Self {
|
||||
ApiError(StatusCode::BAD_REQUEST, format!("{:#}", e.into()))
|
||||
}
|
||||
}
|
||||
|
||||
type ApiResult<T = Json<Value>> = Result<T, ApiError>;
|
||||
|
||||
fn not_found(what: &str) -> ApiError {
|
||||
ApiError(StatusCode::NOT_FOUND, format!("{what} not found"))
|
||||
}
|
||||
|
||||
pub fn router(app: AppState) -> Router {
|
||||
let api = Router::new()
|
||||
.route("/info", get(info))
|
||||
.route("/config", get(get_config).patch(patch_config))
|
||||
.route("/config/raw", get(get_config_raw).put(put_config_raw))
|
||||
.route("/providers/reload", post(reload_providers))
|
||||
.route("/models", get(models))
|
||||
.route("/agents", get(agents))
|
||||
.route("/commands", get(commands))
|
||||
.route("/sessions", get(list_sessions).post(create_session))
|
||||
.route("/sessions/{id}", get(get_session).patch(update_session).delete(delete_session))
|
||||
.route("/sessions/{id}/prompt", post(prompt))
|
||||
.route("/sessions/{id}/abort", post(abort))
|
||||
.route("/sessions/{id}/compact", post(compact))
|
||||
.route("/sessions/{id}/rewind", post(rewind))
|
||||
.route("/sessions/{id}/stats", get(session_stats))
|
||||
.route("/permissions/{id}", post(reply_permission))
|
||||
.route("/questions/{id}", post(answer_question))
|
||||
.route("/pending", get(pending))
|
||||
.route("/mcp", get(mcp_status))
|
||||
.route("/mcp/reload", post(mcp_reload))
|
||||
.route("/fs/dirs", get(list_dirs))
|
||||
.route("/stats", get(stats))
|
||||
.route("/ws", get(ws))
|
||||
.layer(middleware::from_fn_with_state(app.clone(), auth));
|
||||
|
||||
let web_dir = std::env::var("CODECRAFT_WEB").map(PathBuf::from).unwrap_or_else(|_| {
|
||||
["../web/dist", "./web/dist", "/app/web"]
|
||||
.iter()
|
||||
.map(PathBuf::from)
|
||||
.find(|p| p.join("index.html").exists())
|
||||
.unwrap_or_else(|| PathBuf::from("./web/dist"))
|
||||
});
|
||||
let index = web_dir.join("index.html");
|
||||
let static_files = tower_http::services::ServeDir::new(&web_dir)
|
||||
.fallback(tower_http::services::ServeFile::new(index));
|
||||
|
||||
Router::new().nest("/api", api).fallback_service(static_files).with_state(app)
|
||||
}
|
||||
|
||||
/// Bearer token, `token` query parameter (WebSocket) or `codecraft_token` cookie.
|
||||
async fn auth(State(app): State<AppState>, req: Request, next: Next) -> Response {
|
||||
let expected = app.config().auth_token();
|
||||
if expected.is_empty() || req.uri().path().ends_with("/info") {
|
||||
return next.run(req).await;
|
||||
}
|
||||
let header_token = req
|
||||
.headers()
|
||||
.get(header::AUTHORIZATION)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.and_then(|v| v.strip_prefix("Bearer "))
|
||||
.map(String::from);
|
||||
let query_token = req.uri().query().and_then(|q| {
|
||||
q.split('&').find_map(|kv| kv.strip_prefix("token=")).map(String::from)
|
||||
});
|
||||
let cookie_token = req
|
||||
.headers()
|
||||
.get(header::COOKIE)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.and_then(|c| c.split(';').find_map(|kv| kv.trim().strip_prefix("codecraft_token=")).map(String::from));
|
||||
let ok = [header_token, query_token, cookie_token]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.any(|t| constant_time_eq(t.as_bytes(), expected.as_bytes()));
|
||||
if ok {
|
||||
next.run(req).await
|
||||
} else {
|
||||
ApiError(StatusCode::UNAUTHORIZED, "unauthorized".into()).into_response()
|
||||
}
|
||||
}
|
||||
|
||||
fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
|
||||
a.len() == b.len() && a.iter().zip(b).fold(0u8, |acc, (x, y)| acc | (x ^ y)) == 0
|
||||
}
|
||||
|
||||
// --- info & config ----------------------------------------------------------
|
||||
|
||||
async fn info(State(app): State<AppState>) -> Json<Value> {
|
||||
let cfg = app.config();
|
||||
Json(json!({
|
||||
"name": "CodeCraft",
|
||||
"version": env!("CARGO_PKG_VERSION"),
|
||||
"auth_required": !cfg.auth_token().is_empty(),
|
||||
}))
|
||||
}
|
||||
|
||||
async fn get_config(State(app): State<AppState>) -> ApiResult {
|
||||
let cfg = app.config();
|
||||
Ok(Json(json!({
|
||||
"config": config::redacted_json(&cfg),
|
||||
"workspace": app.workspace(),
|
||||
"default_model": if cfg.server.default_model.is_empty() { app.registry().first_model() } else { Some(cfg.server.default_model.clone()) },
|
||||
"currency": cfg.server.currency,
|
||||
})))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct PatchBody {
|
||||
patches: Vec<ConfigPatch>,
|
||||
}
|
||||
|
||||
async fn patch_config(State(app): State<AppState>, Json(body): Json<PatchBody>) -> ApiResult {
|
||||
// Masked secrets coming back from the UI must not overwrite real values.
|
||||
let patches: Vec<ConfigPatch> = body.patches.into_iter().filter(|p| p.value.as_str() != Some("••••••••")).collect();
|
||||
app.patch_config(&patches).await?;
|
||||
Ok(Json(json!({"ok": true})))
|
||||
}
|
||||
|
||||
async fn get_config_raw(State(app): State<AppState>) -> ApiResult {
|
||||
Ok(Json(json!({"text": app.config_text()?})))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct RawBody {
|
||||
text: String,
|
||||
}
|
||||
|
||||
async fn put_config_raw(State(app): State<AppState>, Json(body): Json<RawBody>) -> ApiResult {
|
||||
app.save_config(&body.text).await?;
|
||||
Ok(Json(json!({"ok": true})))
|
||||
}
|
||||
|
||||
async fn reload_providers(State(app): State<AppState>) -> ApiResult {
|
||||
app.reload_providers().await;
|
||||
Ok(Json(app.registry().models_json()))
|
||||
}
|
||||
|
||||
async fn models(State(app): State<AppState>) -> Json<Value> {
|
||||
Json(app.registry().models_json())
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct CwdQuery {
|
||||
cwd: Option<String>,
|
||||
}
|
||||
|
||||
fn cwd_or_workspace(app: &App, cwd: Option<String>) -> PathBuf {
|
||||
cwd.map(PathBuf::from).unwrap_or_else(|| app.workspace())
|
||||
}
|
||||
|
||||
async fn agents(State(app): State<AppState>, Query(q): Query<CwdQuery>) -> Json<Value> {
|
||||
let cwd = cwd_or_workspace(&app, q.cwd);
|
||||
Json(json!(prompts::agents(&app, &cwd)))
|
||||
}
|
||||
|
||||
async fn commands(State(app): State<AppState>, Query(q): Query<CwdQuery>) -> Json<Value> {
|
||||
let cwd = cwd_or_workspace(&app, q.cwd);
|
||||
Json(json!({
|
||||
"commands": prompts::commands(&app, &cwd),
|
||||
"skills": prompts::skills(&app, &cwd),
|
||||
}))
|
||||
}
|
||||
|
||||
// --- sessions ---------------------------------------------------------------
|
||||
|
||||
async fn list_sessions(State(app): State<AppState>) -> ApiResult {
|
||||
let sessions = app.store.list_sessions()?;
|
||||
let list: Vec<Value> = sessions
|
||||
.into_iter()
|
||||
.map(|s| {
|
||||
let busy = app.is_busy(&s.id);
|
||||
let mut v = json!(s);
|
||||
v["busy"] = json!(busy);
|
||||
v
|
||||
})
|
||||
.collect();
|
||||
Ok(Json(json!(list)))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct CreateSession {
|
||||
cwd: Option<String>,
|
||||
model: Option<String>,
|
||||
agent: Option<String>,
|
||||
permission_mode: Option<String>,
|
||||
}
|
||||
|
||||
fn validate_cwd(app: &App, cwd: Option<String>) -> Result<PathBuf, ApiError> {
|
||||
let ws = app.workspace();
|
||||
let p = match cwd.filter(|c| !c.trim().is_empty()) {
|
||||
Some(c) if std::path::Path::new(&c).is_absolute() => PathBuf::from(c),
|
||||
Some(c) => ws.join(c),
|
||||
None => ws.clone(),
|
||||
};
|
||||
let p = std::fs::canonicalize(&p).map_err(|_| ApiError(StatusCode::BAD_REQUEST, format!("directory does not exist: {}", p.display())))?;
|
||||
if !p.starts_with(&ws) || !p.is_dir() {
|
||||
return Err(ApiError(StatusCode::BAD_REQUEST, "directory must be inside the workspace".into()));
|
||||
}
|
||||
Ok(p)
|
||||
}
|
||||
|
||||
async fn create_session(State(app): State<AppState>, Json(body): Json<CreateSession>) -> ApiResult {
|
||||
let cwd = validate_cwd(&app, body.cwd)?;
|
||||
let cfg = app.config();
|
||||
let model = body
|
||||
.model
|
||||
.filter(|m| !m.is_empty())
|
||||
.or_else(|| Some(cfg.server.default_model.clone()).filter(|m| !m.is_empty()))
|
||||
.or_else(|| app.registry().first_model())
|
||||
.ok_or_else(|| ApiError(StatusCode::BAD_REQUEST, "no model configured – add a provider in the settings".into()))?;
|
||||
let now = now_ms();
|
||||
let s = Session {
|
||||
id: new_id(),
|
||||
title: String::new(),
|
||||
cwd: cwd.display().to_string(),
|
||||
model,
|
||||
agent: body.agent.unwrap_or_else(|| "build".into()),
|
||||
permission_mode: body.permission_mode.unwrap_or_else(|| "default".into()),
|
||||
parent_id: None,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
todos: Value::Null,
|
||||
last_usage: None,
|
||||
};
|
||||
let s = app.save_session(s)?;
|
||||
Ok(Json(json!(s)))
|
||||
}
|
||||
|
||||
async fn get_session(State(app): State<AppState>, Path(id): Path<String>) -> ApiResult {
|
||||
let s = app.store.get_session(&id)?.ok_or_else(|| not_found("session"))?;
|
||||
let messages = app.store.messages(&id)?;
|
||||
let busy = app.is_busy(&id);
|
||||
Ok(Json(json!({"session": s, "messages": messages, "busy": busy})))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct UpdateSession {
|
||||
title: Option<String>,
|
||||
model: Option<String>,
|
||||
agent: Option<String>,
|
||||
permission_mode: Option<String>,
|
||||
}
|
||||
|
||||
async fn update_session(State(app): State<AppState>, Path(id): Path<String>, Json(body): Json<UpdateSession>) -> ApiResult {
|
||||
let mut s = app.store.get_session(&id)?.ok_or_else(|| not_found("session"))?;
|
||||
if let Some(t) = body.title {
|
||||
s.title = t;
|
||||
}
|
||||
if let Some(m) = body.model {
|
||||
app.registry().resolve(&m)?;
|
||||
s.model = m;
|
||||
}
|
||||
if let Some(a) = body.agent {
|
||||
s.agent = a;
|
||||
}
|
||||
if let Some(p) = body.permission_mode {
|
||||
if !["default", "accept_edits", "bypass"].contains(&p.as_str()) {
|
||||
return Err(ApiError(StatusCode::BAD_REQUEST, "invalid permission mode".into()));
|
||||
}
|
||||
s.permission_mode = p;
|
||||
}
|
||||
Ok(Json(json!(app.save_session(s)?)))
|
||||
}
|
||||
|
||||
async fn delete_session(State(app): State<AppState>, Path(id): Path<String>) -> ApiResult {
|
||||
runner::abort(&app, &id);
|
||||
app.store.delete_session(&id)?;
|
||||
app.emit(Event::SessionDeleted { session_id: id });
|
||||
Ok(Json(json!({"ok": true})))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct PromptBody {
|
||||
text: String,
|
||||
#[serde(default)]
|
||||
images: Vec<ImageData>,
|
||||
}
|
||||
|
||||
async fn prompt(State(app): State<AppState>, Path(id): Path<String>, Json(body): Json<PromptBody>) -> ApiResult {
|
||||
let session = app.store.get_session(&id)?.ok_or_else(|| not_found("session"))?;
|
||||
let text = body.text.trim().to_string();
|
||||
// Built-in commands.
|
||||
if text == "/compact" {
|
||||
if app.is_busy(&id) {
|
||||
return Err(ApiError(StatusCode::CONFLICT, "session is busy".into()));
|
||||
}
|
||||
tokio::spawn(do_compact(app.clone(), id.clone()));
|
||||
return Ok(Json(json!({"ok": true, "command": "compact"})));
|
||||
}
|
||||
let cwd = PathBuf::from(&session.cwd);
|
||||
let mut prompt_text = text.clone();
|
||||
if text.starts_with('/') {
|
||||
if let Some((expanded, agent)) = prompts::expand_command(&app, &cwd, &text) {
|
||||
prompt_text = expanded;
|
||||
if let Some(a) = agent {
|
||||
let mut s = session.clone();
|
||||
s.agent = a;
|
||||
app.save_session(s)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut parts = vec![Part::Text { text: prompt_text }];
|
||||
for image in body.images {
|
||||
parts.push(Part::Image { image });
|
||||
}
|
||||
runner::start_prompt(app.clone(), id, parts)?;
|
||||
Ok(Json(json!({"ok": true})))
|
||||
}
|
||||
|
||||
async fn do_compact(app: AppState, id: String) {
|
||||
let cancel = tokio_util::sync::CancellationToken::new();
|
||||
app.with_runtime(&id, |r| r.cancel = Some(cancel.clone()));
|
||||
app.emit(Event::SessionStatus { session_id: id.clone(), busy: true });
|
||||
if let Err(e) = runner::compact(app.clone(), &id, cancel).await {
|
||||
app.emit(Event::Error { session_id: id.clone(), message: format!("compaction failed: {e:#}") });
|
||||
}
|
||||
app.with_runtime(&id, |r| r.cancel = None);
|
||||
app.emit(Event::SessionStatus { session_id: id, busy: false });
|
||||
}
|
||||
|
||||
async fn abort(State(app): State<AppState>, Path(id): Path<String>) -> Json<Value> {
|
||||
runner::abort(&app, &id);
|
||||
Json(json!({"ok": true}))
|
||||
}
|
||||
|
||||
async fn compact(State(app): State<AppState>, Path(id): Path<String>) -> ApiResult {
|
||||
if app.is_busy(&id) {
|
||||
return Err(ApiError(StatusCode::CONFLICT, "session is busy".into()));
|
||||
}
|
||||
tokio::spawn(do_compact(app.clone(), id));
|
||||
Ok(Json(json!({"ok": true})))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct RewindBody {
|
||||
message_id: String,
|
||||
}
|
||||
|
||||
/// Remove a message and everything after it (edit & resend). Files are not restored.
|
||||
async fn rewind(State(app): State<AppState>, Path(id): Path<String>, Json(body): Json<RewindBody>) -> ApiResult {
|
||||
if app.is_busy(&id) {
|
||||
return Err(ApiError(StatusCode::CONFLICT, "session is busy".into()));
|
||||
}
|
||||
app.store.truncate_from(&id, &body.message_id)?;
|
||||
let s = app.store.get_session(&id)?.ok_or_else(|| not_found("session"))?;
|
||||
let mut s = s;
|
||||
s.last_usage = None;
|
||||
app.save_session(s)?;
|
||||
Ok(Json(json!({"ok": true})))
|
||||
}
|
||||
|
||||
async fn session_stats(State(app): State<AppState>, Path(id): Path<String>) -> ApiResult {
|
||||
let mut v = app.store.session_stats(&id)?;
|
||||
v["currency"] = json!(app.config().server.currency);
|
||||
Ok(Json(v))
|
||||
}
|
||||
|
||||
// --- interaction ------------------------------------------------------------
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct PermissionBody {
|
||||
reply: PermissionReply,
|
||||
message: Option<String>,
|
||||
}
|
||||
|
||||
async fn reply_permission(State(app): State<AppState>, Path(id): Path<String>, Json(body): Json<PermissionBody>) -> ApiResult {
|
||||
app.reply_permission(&id, body.reply, body.message)?;
|
||||
Ok(Json(json!({"ok": true})))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct QuestionBody {
|
||||
/// question text → answer (string or array of strings); null = dismissed
|
||||
answers: Option<Map<String, Value>>,
|
||||
}
|
||||
|
||||
async fn answer_question(State(app): State<AppState>, Path(id): Path<String>, Json(body): Json<QuestionBody>) -> ApiResult {
|
||||
app.answer_question(&id, body.answers)?;
|
||||
Ok(Json(json!({"ok": true})))
|
||||
}
|
||||
|
||||
async fn pending(State(app): State<AppState>) -> Json<Value> {
|
||||
Json(json!(app.pending_events()))
|
||||
}
|
||||
|
||||
async fn mcp_status(State(app): State<AppState>) -> Json<Value> {
|
||||
Json(app.mcp.status_json())
|
||||
}
|
||||
|
||||
async fn mcp_reload(State(app): State<AppState>) -> Json<Value> {
|
||||
let mcp = app.mcp.clone();
|
||||
let servers = app.config().mcp.clone();
|
||||
let weak = Arc::downgrade(&app);
|
||||
mcp.start_all(weak, servers).await;
|
||||
Json(app.mcp.status_json())
|
||||
}
|
||||
|
||||
// --- files ------------------------------------------------------------------
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct DirQuery {
|
||||
path: Option<String>,
|
||||
}
|
||||
|
||||
/// Subdirectories for the project picker (inside the workspace only).
|
||||
async fn list_dirs(State(app): State<AppState>, Query(q): Query<DirQuery>) -> ApiResult {
|
||||
let dir = validate_cwd(&app, q.path)?;
|
||||
let mut dirs = Vec::new();
|
||||
for e in std::fs::read_dir(&dir)?.flatten() {
|
||||
let name = e.file_name().to_string_lossy().to_string();
|
||||
if name.starts_with('.') || !e.file_type().map(|t| t.is_dir()).unwrap_or(false) {
|
||||
continue;
|
||||
}
|
||||
dirs.push(json!({"name": name, "path": e.path(), "git": e.path().join(".git").exists()}));
|
||||
}
|
||||
dirs.sort_by(|a, b| a["name"].as_str().cmp(&b["name"].as_str()));
|
||||
let ws = app.workspace();
|
||||
Ok(Json(json!({
|
||||
"path": dir,
|
||||
"parent": if dir == ws { Value::Null } else { json!(dir.parent()) },
|
||||
"workspace": ws,
|
||||
"dirs": dirs,
|
||||
})))
|
||||
}
|
||||
|
||||
// --- statistics -------------------------------------------------------------
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct StatsQuery {
|
||||
from: Option<i64>,
|
||||
to: Option<i64>,
|
||||
/// Minutes east of UTC (JS: -new Date().getTimezoneOffset()).
|
||||
tz: Option<i64>,
|
||||
}
|
||||
|
||||
async fn stats(State(app): State<AppState>, Query(q): Query<StatsQuery>) -> ApiResult {
|
||||
let to = q.to.unwrap_or_else(|| now_ms() + 1);
|
||||
let from = q.from.unwrap_or(to - 30 * 86_400_000);
|
||||
let mut v = app.store.global_stats(from, to, q.tz.unwrap_or(0))?;
|
||||
v["currency"] = json!(app.config().server.currency);
|
||||
v["from"] = json!(from);
|
||||
v["to"] = json!(to);
|
||||
Ok(Json(v))
|
||||
}
|
||||
|
||||
// --- websocket --------------------------------------------------------------
|
||||
|
||||
async fn ws(State(app): State<AppState>, upgrade: WebSocketUpgrade) -> Response {
|
||||
upgrade.on_upgrade(move |socket| ws_loop(app, socket))
|
||||
}
|
||||
|
||||
async fn ws_loop(app: AppState, mut socket: WebSocket) {
|
||||
let mut rx = app.subscribe();
|
||||
for ev in app.pending_events() {
|
||||
if send_event(&mut socket, &ev).await.is_err() {
|
||||
return;
|
||||
}
|
||||
}
|
||||
let mut ping = tokio::time::interval(std::time::Duration::from_secs(25));
|
||||
loop {
|
||||
tokio::select! {
|
||||
ev = rx.recv() => match ev {
|
||||
Ok(ev) => {
|
||||
if send_event(&mut socket, &ev).await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => {
|
||||
// Client fell behind: tell it to resync.
|
||||
let msg = json!({"type": "resync", "skipped": n}).to_string();
|
||||
if socket.send(WsMessage::Text(msg.into())).await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Err(_) => break,
|
||||
},
|
||||
msg = socket.recv() => match msg {
|
||||
Some(Ok(WsMessage::Close(_))) | None | Some(Err(_)) => break,
|
||||
_ => {}
|
||||
},
|
||||
_ = ping.tick() => {
|
||||
if socket.send(WsMessage::Ping(Vec::new().into())).await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn send_event(socket: &mut WebSocket, ev: &Event) -> Result<(), axum::Error> {
|
||||
let text = serde_json::to_string(ev).unwrap_or_default();
|
||||
socket.send(WsMessage::Text(text.into())).await
|
||||
}
|
||||
Reference in new issue
Block a user