## Problem
Convex backend RSS grew to 7.7G due to ~17 HTTP POST /api/query/min
(32K+ in 32h) from the SSE polling loop in /api/tree/events?pollMs=1000.
Each poll triggered a Convex query even when nothing changed.
## Root Cause
The tree live EventSource client polled every 1s via SSE, calling
load_stream_overview() → execute_runtime_query_via_convex() → Convex
POST /api/query on every cycle, regardless of workspace state.
## Solution
Replace polling with push: add a `stream_delta_tx` broadcast channel
that publishes after every Convex mutation, consumed by WebSocket and
SSE endpoints for push-only delivery.
### Server-side
- **app.rs**: Add `stream_delta_tx: broadcast::Sender<Value>` to AppState
- **command_support.rs**: `execute_runtime_command_via_convex_with_artifacts`
now takes `&AppState` (was `&AppConfig`) and pushes `{"kind":"command_committed",...}`
to `stream_delta_tx` after every successful mutation
- **ws.rs**: Rewrite `handle_socket` with `tokio::select!` subscribing to
`stream_delta_tx`; pushes delta events to WS clients on mutation, handles
client `resync` requests for fresh snapshots
- **sse.rs**: `tree_events` endpoint now subscribes to both `block_delta_tx`
and `stream_delta_tx`; when broadcast channels are available, runs in
push-only mode (250ms heartbeat, no Convex query). Polling degrades to
60s safety net. Keeps backward compatibility for non-WS clients.
### Client-side
- **layout.rs**: Bootstrap JSON now defaults to `transport: "convex-command-log-ws"`
with `wsEndpoint: "/api/realtime/ws"`. TREE_LIVE_CONTROLLER_JS extended
with `startWithWebSocket()` supporting snapshot/delta/resync/lagged-hint
events; auto-fallback to SSE on WS failure after 2s.
### Caller updates (17 call sites)
- documents.rs, mindmap_api.rs, resource_trash.rs, tree.rs
- hermes_tools/{artifact,block,page}.rs
All updated from `state.config()` to `&state` for the new signature.
## Verification
- `cargo build` + `cargo test`: 295/298 passed (3 pre-existing failures)
- Browser smoke: page loaded → transport=convex-command-log-ws, status=connected
- Convex logs: 0 POST /api/query in 2min with page idle (vs ~17/min before)
- Initial burst: 8 queries on page load (normal), then silence
179 lines
6.8 KiB
Rust
179 lines
6.8 KiB
Rust
use crate::acp_runtime::AcpRuntimeManager;
|
|
use crate::editor_actor::EditorRuntimeActor;
|
|
use crate::local_folder_watcher_registry::LocalFolderWatcherRegistry;
|
|
use crate::middleware::request_context::inject_request_context;
|
|
use crate::routes::build_router;
|
|
use axum::Router;
|
|
use std::env;
|
|
use std::fs;
|
|
use std::sync::Arc;
|
|
use tower_http::trace::TraceLayer;
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub struct AppConfig {
|
|
pub service_name: String,
|
|
pub service_version: String,
|
|
pub bind_addr: String,
|
|
pub public_bind_addr: String,
|
|
pub legacy_next_base_url: Option<String>,
|
|
pub enable_legacy_next_compat: bool,
|
|
pub enable_debug_shell_routes: bool,
|
|
pub enable_editor_actor: bool,
|
|
pub hermes_base_path: String,
|
|
pub compat_next_base_path: String,
|
|
pub convex_url: Option<String>,
|
|
pub convex_admin_key: Option<String>,
|
|
pub allow_dev_fixtures: bool,
|
|
pub query_fixtures_json: Option<String>,
|
|
pub mutation_fixtures_json: Option<String>,
|
|
pub dev_user_id: String,
|
|
pub dev_user_name: String,
|
|
pub dev_user_email: String,
|
|
}
|
|
|
|
impl AppConfig {
|
|
pub fn from_env() -> Self {
|
|
Self {
|
|
service_name: env::var("MNOTE_WEB_SERVICE_NAME").unwrap_or_else(|_| "mnote-web".into()),
|
|
service_version: env::var("MNOTE_WEB_SERVICE_VERSION")
|
|
.unwrap_or_else(|_| env!("CARGO_PKG_VERSION").into()),
|
|
bind_addr: env::var("MNOTE_WEB_BIND")
|
|
.or_else(|_| env::var("MNOTE_WEB_PUBLIC_BIND"))
|
|
.unwrap_or_else(|_| "127.0.0.1:0".into()),
|
|
public_bind_addr: env::var("MNOTE_WEB_PUBLIC_BIND")
|
|
.unwrap_or_else(|_| "127.0.0.1:3000".into()),
|
|
legacy_next_base_url: env::var("MNOTE_WEB_LEGACY_NEXT_BASE_URL")
|
|
.ok()
|
|
.map(|value| value.trim().trim_end_matches('/').to_string())
|
|
.filter(|value| !value.is_empty()),
|
|
enable_legacy_next_compat: env_bool("MNOTE_WEB_ENABLE_LEGACY_NEXT_COMPAT", false),
|
|
enable_debug_shell_routes: env::var("MNOTE_WEB_ENABLE_DEBUG_SHELL_ROUTES")
|
|
.ok()
|
|
.map(|value| matches!(value.trim(), "1" | "true" | "TRUE" | "yes" | "YES"))
|
|
.unwrap_or(false),
|
|
enable_editor_actor: env_bool("MNOTE_WEB_ENABLE_EDITOR_ACTOR", true),
|
|
hermes_base_path: env::var("MNOTE_WEB_HERMES_BASE_PATH")
|
|
.unwrap_or_else(|_| "/api/hermes".into()),
|
|
compat_next_base_path: env::var("MNOTE_WEB_COMPAT_NEXT_BASE_PATH")
|
|
.unwrap_or_else(|_| "/api/compat/next".into()),
|
|
convex_url: env::var("CONVEX_SELF_HOSTED_URL")
|
|
.ok()
|
|
.or_else(|| env::var("NEXT_PUBLIC_CONVEX_URL").ok())
|
|
.or_else(|| read_env_or_dotenv("CONVEX_SELF_HOSTED_URL"))
|
|
.or_else(|| read_env_or_dotenv("NEXT_PUBLIC_CONVEX_URL"))
|
|
.map(|value| value.trim().trim_end_matches('/').to_string())
|
|
.filter(|value| !value.is_empty()),
|
|
convex_admin_key: env::var("CONVEX_SELF_HOSTED_ADMIN_KEY")
|
|
.ok()
|
|
.or_else(|| read_env_or_dotenv("CONVEX_SELF_HOSTED_ADMIN_KEY"))
|
|
.map(|value| value.trim().to_string())
|
|
.filter(|value| !value.is_empty()),
|
|
allow_dev_fixtures: env::var("MNOTE_WEB_ALLOW_DEV_FIXTURES")
|
|
.ok()
|
|
.map(|value| matches!(value.trim(), "1" | "true" | "TRUE" | "yes" | "YES"))
|
|
.unwrap_or(false),
|
|
query_fixtures_json: env::var("MNOTE_WEB_QUERY_FIXTURES_JSON")
|
|
.ok()
|
|
.map(|value| value.trim().to_string())
|
|
.filter(|value| !value.is_empty()),
|
|
mutation_fixtures_json: env::var("MNOTE_WEB_MUTATION_FIXTURES_JSON")
|
|
.ok()
|
|
.map(|value| value.trim().to_string())
|
|
.filter(|value| !value.is_empty()),
|
|
dev_user_id: env::var("DEV_USER_ID")
|
|
.ok()
|
|
.or_else(|| read_env_or_dotenv("DEV_USER_ID"))
|
|
.unwrap_or_else(|| "dev-user".into()),
|
|
dev_user_name: env::var("DEV_USER_NAME")
|
|
.ok()
|
|
.or_else(|| read_env_or_dotenv("DEV_USER_NAME"))
|
|
.unwrap_or_else(|| "开发用户".into()),
|
|
dev_user_email: env::var("DEV_USER_EMAIL")
|
|
.ok()
|
|
.or_else(|| read_env_or_dotenv("DEV_USER_EMAIL"))
|
|
.unwrap_or_else(|| "dev@mnote.local".into()),
|
|
}
|
|
}
|
|
}
|
|
|
|
fn env_bool(key: &str, default: bool) -> bool {
|
|
env::var(key)
|
|
.ok()
|
|
.map(|value| matches!(value.trim(), "1" | "true" | "TRUE" | "yes" | "YES"))
|
|
.unwrap_or(default)
|
|
}
|
|
|
|
fn read_env_or_dotenv(key: &str) -> Option<String> {
|
|
if let Ok(value) = env::var(key) {
|
|
let trimmed = value.trim().trim_matches('"').to_string();
|
|
if !trimmed.is_empty() {
|
|
return Some(trimmed);
|
|
}
|
|
}
|
|
|
|
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
|
.join("../../..")
|
|
.join(".env.all");
|
|
let content = fs::read_to_string(root).ok()?;
|
|
for line in content.lines() {
|
|
let line = line.trim_end_matches('\r');
|
|
if line.starts_with('#') || line.trim().is_empty() {
|
|
continue;
|
|
}
|
|
let Some((k, v)) = line.split_once('=') else {
|
|
continue;
|
|
};
|
|
if k.trim() != key {
|
|
continue;
|
|
}
|
|
let trimmed = v.trim().trim_matches('"').to_string();
|
|
if !trimmed.is_empty() {
|
|
return Some(trimmed);
|
|
}
|
|
}
|
|
None
|
|
}
|
|
|
|
use tokio::sync::broadcast;
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub struct AppState {
|
|
config: Arc<AppConfig>,
|
|
local_folder_watcher_registry: LocalFolderWatcherRegistry,
|
|
pub editor_actor: EditorRuntimeActor,
|
|
pub block_delta_tx: broadcast::Sender<serde_json::Value>,
|
|
pub stream_delta_tx: broadcast::Sender<serde_json::Value>,
|
|
pub acp_runtime: Arc<AcpRuntimeManager>,
|
|
}
|
|
|
|
impl AppState {
|
|
pub fn new(config: AppConfig) -> Self {
|
|
let (block_delta_tx, _) = broadcast::channel(256);
|
|
let (stream_delta_tx, _) = broadcast::channel(256);
|
|
let actor = EditorRuntimeActor::new();
|
|
actor.set_block_delta_tx(block_delta_tx.clone());
|
|
Self {
|
|
config: Arc::new(config),
|
|
local_folder_watcher_registry: LocalFolderWatcherRegistry::new(),
|
|
editor_actor: actor,
|
|
block_delta_tx,
|
|
stream_delta_tx,
|
|
acp_runtime: Arc::new(AcpRuntimeManager::from_env()),
|
|
}
|
|
}
|
|
|
|
pub fn config(&self) -> &AppConfig {
|
|
self.config.as_ref()
|
|
}
|
|
|
|
pub fn local_folder_watcher_registry(&self) -> &LocalFolderWatcherRegistry {
|
|
&self.local_folder_watcher_registry
|
|
}
|
|
}
|
|
|
|
pub fn build_app(state: AppState) -> Router {
|
|
build_router(state)
|
|
.layer(TraceLayer::new_for_http())
|
|
.layer(axum::middleware::from_fn(inject_request_context))
|
|
}
|