Fix tiptap selection sync and toolbar event loop

This commit is contained in:
lix-2026
2026-04-19 21:03:25 +08:00
parent 111a87d4fd
commit 394e2a155c
87 changed files with 17415 additions and 527 deletions
+2
View File
@@ -11,9 +11,11 @@ bridge-runtime = { path = "../bridge-runtime" }
core-protocol = { path = "../core-protocol" }
futures-util = "0.3"
leptos = { version = "0.8.14", default-features = false, features = ["ssr"] }
mnote-editor-core = { path = "../mnote-editor-core" }
reqwest = { version = "0.12", default-features = false, features = ["blocking", "json", "rustls-tls"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
storage-convex-bridge = { path = "../storage-convex-bridge" }
tokio = { version = "1", features = ["macros", "rt-multi-thread", "net", "signal", "sync", "time"] }
tower-http = { version = "0.6", features = ["trace"] }
tracing = "0.1"
@@ -0,0 +1,661 @@
use crate::app::AppState;
use crate::context::RequestContext;
use crate::error::WebError;
use crate::routes::command_support::execute_runtime_command_via_convex;
use crate::routes::query_support::{
execute_runtime_query_via_convex, fetch_documents_meta_via_convex,
resolve_effective_workspace_id,
};
use axum::extract::{Extension, Query, State};
use axum::http::StatusCode;
use axum::Json;
use bridge_runtime::RuntimeQueryEnvelopeWire;
use bridge_runtime::{
RuntimeActorWire, RuntimeCommandEnvelopeWire, RuntimeSourceWire, RuntimeTargetWire,
};
use serde::Deserialize;
use serde_json::{json, Value};
use std::fs;
use std::time::Duration;
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct DocumentContentQuery {
pub document_id: String,
pub workspace_id: Option<String>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct DocumentMetaQuery {
pub document_id: String,
pub workspace_id: Option<String>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct DocumentSaveRequest {
pub document_id: String,
pub workspace_id: Option<String>,
pub revision: Option<u64>,
pub conflict_detection_key: Option<String>,
pub content: Value,
pub snapshot_captured_at: Option<String>,
pub block_count: Option<u64>,
}
const NEXT_DOCUMENTS_BASE_URL_ENV: &str = "MNOTE_NEXT_BASE_URL";
fn ok_response(context: &RequestContext, result: Value) -> (StatusCode, Json<Value>) {
(
StatusCode::OK,
Json(json!({
"ok": true,
"requestId": context.trace.request_id,
"traceId": context.trace.trace_id,
"result": result,
})),
)
}
fn read_env_or_dotenv(key: &str) -> Option<String> {
if let Ok(value) = std::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
}
fn next_documents_base_url() -> String {
read_env_or_dotenv(NEXT_DOCUMENTS_BASE_URL_ENV)
.unwrap_or_else(|| "http://127.0.0.1:3000".into())
.trim()
.trim_end_matches('/')
.to_string()
}
fn should_proxy_via_next(context: &RequestContext) -> bool {
context
.auth
.cookie_header
.as_deref()
.map(str::trim)
.is_some_and(|value| !value.is_empty())
}
fn build_next_proxy_headers(
context: &RequestContext,
effective_workspace_id: Option<&str>,
) -> reqwest::header::HeaderMap {
use reqwest::header::{HeaderMap, HeaderName, HeaderValue};
fn insert(headers: &mut HeaderMap, name: &'static str, value: &str) {
let Ok(header_name) = HeaderName::from_lowercase(name.as_bytes()) else {
return;
};
let Ok(header_value) = HeaderValue::from_str(value) else {
return;
};
headers.insert(header_name, header_value);
}
let mut headers = HeaderMap::new();
if let Some(cookie) = context.auth.cookie_header.as_deref() {
insert(&mut headers, "cookie", cookie);
}
if let Some(authorization) = context.auth.authorization.as_deref() {
insert(&mut headers, "authorization", authorization);
}
insert(&mut headers, "x-request-id", &context.trace.request_id);
insert(&mut headers, "x-trace-id", &context.trace.trace_id);
insert(
&mut headers,
"x-mnote-source-channel",
&context.source.channel,
);
insert(
&mut headers,
"x-mnote-source-client",
&context.source.client,
);
insert(&mut headers, "x-mnote-actor-id", &context.auth.actor_id);
insert(&mut headers, "x-mnote-actor-type", &context.auth.actor_type);
if let Some(session_id) = context.auth.session_id.as_deref() {
insert(&mut headers, "x-mnote-session-id", session_id);
}
if let Some(workspace_id) = effective_workspace_id {
insert(&mut headers, "x-mnote-workspace-id", workspace_id);
}
headers
}
async fn send_next_documents_request(
context: &RequestContext,
request: reqwest::RequestBuilder,
phase: &'static str,
) -> Result<Value, WebError> {
let response = request.send().await.map_err(|error| {
let base = if error.is_timeout() {
WebError::gateway_timeout_code(
"next_proxy_timeout",
format!("Next compat 请求超时: {error}"),
)
} else {
WebError::service_unavailable_code(
"next_proxy_unavailable",
format!("Next compat 请求失败: {error}"),
)
};
base.with_context(context)
.with_header("x-error-phase", phase)
.with_header("x-upstream-service", "next")
})?;
let status = response.status();
let text = response.text().await.map_err(|error| {
WebError::bad_gateway_code(
"next_proxy_bad_response",
format!("Next compat 响应读取失败: {error}"),
)
.with_context(context)
.with_header("x-error-phase", phase)
.with_header("x-upstream-service", "next")
.with_header("x-upstream-status", status.as_u16().to_string())
})?;
let payload = serde_json::from_str::<Value>(&text).map_err(|_| {
let snippet: String = text.chars().take(180).collect();
WebError::bad_gateway_code(
"next_proxy_bad_response",
format!("Next compat 返回了非 JSON 内容: {snippet}"),
)
.with_context(context)
.with_header("x-error-phase", phase)
.with_header("x-upstream-service", "next")
.with_header("x-upstream-status", status.as_u16().to_string())
})?;
if !status.is_success() {
let message = payload
.get("error")
.and_then(Value::as_str)
.or_else(|| payload.get("message").and_then(Value::as_str))
.unwrap_or("Next compat 文档接口请求失败");
let web_error = match status {
reqwest::StatusCode::UNAUTHORIZED | reqwest::StatusCode::FORBIDDEN => {
WebError::new(StatusCode::UNAUTHORIZED, "next_proxy_unauthorized", message)
}
reqwest::StatusCode::NOT_FOUND => {
WebError::new(StatusCode::NOT_FOUND, "next_proxy_not_found", message)
}
_ => WebError::bad_gateway_code("next_proxy_error", message),
};
return Err(web_error
.with_context(context)
.with_header("x-error-phase", phase)
.with_header("x-upstream-service", "next")
.with_header("x-upstream-status", status.as_u16().to_string()));
}
Ok(payload)
}
async fn proxy_next_documents_meta(
context: &RequestContext,
effective_workspace_id: Option<&str>,
document_id: &str,
) -> Result<Value, WebError> {
let base_url = next_documents_base_url();
let mut url =
reqwest::Url::parse(&format!("{base_url}/api/documents/meta")).map_err(|error| {
WebError::internal(format!("Next compat meta URL 非法: {error}"))
.with_context(context)
.with_header("x-error-phase", "next_proxy_meta_url")
.with_header("x-upstream-service", "next")
})?;
url.query_pairs_mut().append_pair("documentId", document_id);
if let Some(workspace_id) = effective_workspace_id {
url.query_pairs_mut()
.append_pair("workspaceId", workspace_id);
}
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(20))
.redirect(reqwest::redirect::Policy::none())
.build()
.map_err(|error| {
WebError::internal(format!("Next compat HTTP 客户端创建失败: {error}"))
.with_context(context)
.with_header("x-error-phase", "next_proxy_meta_client")
.with_header("x-upstream-service", "next")
})?;
let payload = send_next_documents_request(
context,
client
.get(url)
.headers(build_next_proxy_headers(context, effective_workspace_id)),
"next_proxy_meta",
)
.await?;
Ok(payload.get("doc").cloned().unwrap_or(Value::Null))
}
async fn proxy_next_documents_content(
context: &RequestContext,
effective_workspace_id: Option<&str>,
document_id: &str,
) -> Result<Value, WebError> {
let base_url = next_documents_base_url();
let mut url =
reqwest::Url::parse(&format!("{base_url}/api/documents/content")).map_err(|error| {
WebError::internal(format!("Next compat content URL 非法: {error}"))
.with_context(context)
.with_header("x-error-phase", "next_proxy_content_url")
.with_header("x-upstream-service", "next")
})?;
url.query_pairs_mut().append_pair("documentId", document_id);
if let Some(workspace_id) = effective_workspace_id {
url.query_pairs_mut()
.append_pair("workspaceId", workspace_id);
}
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(20))
.redirect(reqwest::redirect::Policy::none())
.build()
.map_err(|error| {
WebError::internal(format!("Next compat HTTP 客户端创建失败: {error}"))
.with_context(context)
.with_header("x-error-phase", "next_proxy_content_client")
.with_header("x-upstream-service", "next")
})?;
let payload = send_next_documents_request(
context,
client
.get(url)
.headers(build_next_proxy_headers(context, effective_workspace_id)),
"next_proxy_content",
)
.await?;
Ok(json!({
"content": payload.get("content").cloned().unwrap_or(Value::Null),
"revision": payload.get("revision").cloned().unwrap_or(Value::Null),
"conflictDetectionKey": payload.get("conflictDetectionKey").cloned().unwrap_or(Value::Null),
"pageSubtree": payload.get("pageSubtree").cloned().unwrap_or(Value::Null),
}))
}
async fn proxy_next_documents_save(
context: &RequestContext,
effective_workspace_id: Option<&str>,
body: &DocumentSaveRequest,
) -> Result<Value, WebError> {
let base_url = next_documents_base_url();
let url = reqwest::Url::parse(&format!("{base_url}/api/documents/save")).map_err(|error| {
WebError::internal(format!("Next compat save URL 非法: {error}"))
.with_context(context)
.with_header("x-error-phase", "next_proxy_save_url")
.with_header("x-upstream-service", "next")
})?;
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(20))
.redirect(reqwest::redirect::Policy::none())
.build()
.map_err(|error| {
WebError::internal(format!("Next compat HTTP 客户端创建失败: {error}"))
.with_context(context)
.with_header("x-error-phase", "next_proxy_save_client")
.with_header("x-upstream-service", "next")
})?;
let payload = send_next_documents_request(
context,
client
.post(url)
.headers(build_next_proxy_headers(context, effective_workspace_id))
.json(&json!({
"documentId": body.document_id,
"workspaceId": effective_workspace_id,
"revision": body.revision,
"conflictDetectionKey": body.conflict_detection_key,
"content": body.content,
"snapshotCapturedAt": body.snapshot_captured_at,
"blockCount": body.block_count,
})),
"next_proxy_save",
)
.await?;
Ok(json!({
"revision": payload.get("revision").cloned().unwrap_or(Value::Null),
"conflictDetectionKey": payload
.get("conflictDetectionKey")
.cloned()
.unwrap_or(Value::Null),
"ok": payload.get("ok").cloned().unwrap_or(Value::Bool(true)),
}))
}
pub async fn meta(
State(state): State<AppState>,
Extension(context): Extension<RequestContext>,
Query(query): Query<DocumentMetaQuery>,
) -> Result<(StatusCode, Json<Value>), WebError> {
let document_id = query.document_id.trim();
if document_id.is_empty() {
return Err(
WebError::bad_request_code("document_id_required", "缺少有效 documentId")
.with_context(&context),
);
}
let effective_workspace_id =
resolve_effective_workspace_id(&context, query.workspace_id.as_deref(), false)?;
if should_proxy_via_next(&context) {
let result =
proxy_next_documents_meta(&context, effective_workspace_id.as_deref(), document_id)
.await?;
return Ok(ok_response(&context, result));
}
let result = fetch_documents_meta_via_convex(
state.config(),
&context,
effective_workspace_id.as_deref(),
document_id,
)
.await?;
Ok(ok_response(&context, result))
}
pub async fn content(
State(state): State<AppState>,
Extension(context): Extension<RequestContext>,
Query(query): Query<DocumentContentQuery>,
) -> Result<(StatusCode, Json<Value>), WebError> {
let document_id = query.document_id.trim();
if document_id.is_empty() {
return Err(
WebError::bad_request_code("document_id_required", "缺少有效 documentId")
.with_context(&context),
);
}
let effective_workspace_id =
resolve_effective_workspace_id(&context, query.workspace_id.as_deref(), false)?;
if should_proxy_via_next(&context) {
let result =
proxy_next_documents_content(&context, effective_workspace_id.as_deref(), document_id)
.await?;
return Ok(ok_response(&context, result));
}
let result = execute_runtime_query_via_convex(
state.config(),
&context,
effective_workspace_id.as_deref(),
RuntimeQueryEnvelopeWire {
name: "documents.content.get".into(),
payload: json!({
"documentId": document_id,
"workspaceId": effective_workspace_id,
}),
},
)
.await?;
Ok(ok_response(&context, result))
}
pub async fn save(
State(state): State<AppState>,
Extension(context): Extension<RequestContext>,
Json(body): Json<DocumentSaveRequest>,
) -> Result<(StatusCode, Json<Value>), WebError> {
let document_id = body.document_id.trim();
if document_id.is_empty() {
return Err(
WebError::bad_request_code("document_id_required", "缺少有效 documentId")
.with_context(&context),
);
}
let effective_workspace_id =
resolve_effective_workspace_id(&context, body.workspace_id.as_deref(), false)?;
if should_proxy_via_next(&context) {
let result =
proxy_next_documents_save(&context, effective_workspace_id.as_deref(), &body).await?;
return Ok(ok_response(&context, result));
}
let command = RuntimeCommandEnvelopeWire {
name: "documents.save".into(),
command_id: format!("document_save_{}", context.trace.request_id),
idempotency_key: context.source.idempotency_key.clone(),
actor: RuntimeActorWire {
actor_type: context.auth.actor_type.clone(),
actor_id: context.auth.actor_id.clone(),
session_id: context.auth.session_id.clone(),
},
source: RuntimeSourceWire {
channel: context.source.channel.clone(),
client: context.source.client.clone(),
},
target: Some(RuntimeTargetWire {
workspace_id: effective_workspace_id.clone(),
page_id: Some(document_id.to_string()),
block_id: None,
}),
payload: json!({
"documentId": document_id,
"workspaceId": effective_workspace_id,
"revision": body.revision,
"conflictDetectionKey": body.conflict_detection_key,
"content": body.content,
"snapshotCapturedAt": body.snapshot_captured_at,
"blockCount": body.block_count,
}),
reason: Some("mnote-web human editor save".into()),
refs: vec!["mnote-web-editor-runtime".into()],
dry_run: false,
validate_only: false,
};
let result = execute_runtime_command_via_convex(
state.config(),
&context,
effective_workspace_id.as_deref(),
command,
)
.await?;
Ok(ok_response(&context, result))
}
#[cfg(test)]
mod tests {
use crate::app::{build_app, AppConfig, AppState};
use axum::body::{to_bytes, Body};
use axum::http::{Request, StatusCode};
use serde_json::Value;
use tower::util::ServiceExt;
fn app() -> axum::Router {
build_app(AppState::new(AppConfig {
service_name: "mnote-web".into(),
service_version: "0.1.0".into(),
bind_addr: "127.0.0.1:0".into(),
hermes_base_path: "/api/hermes".into(),
compat_next_base_path: "/api/compat/next".into(),
convex_url: None,
convex_admin_key: None,
allow_dev_fixtures: true,
query_fixtures_json: Some(
r#"{
"documents:getMeta": {
"id": "doc_1",
"workspace_id": "ws_demo",
"title": "服务端页面",
"updated_at": "2026-04-18T09:30:00Z",
"can_edit": true,
"disable_download": false,
"disable_copy": false,
"wide_layout": false,
"use_small_text": false,
"show_heading_numbers": true,
"show_toc": true,
"show_structure": true,
"protect_editing": false,
"show_word_count": true,
"collapse_backlinks": false,
"page_font": "default",
"layout_density": "normal",
"hide_child_pages": false,
"show_block_ref_count": true,
"embed_default_block_id": "heading_1",
"word_count": 42,
"character_count": 128,
"block_count": 3,
"todo_total": 1,
"todo_done": 0
},
"documents:getContent": {
"title": "服务端页面",
"content": [
{
"id": "heading_1",
"type": "heading",
"props": { "level": 1 },
"content": [{ "type": "text", "text": "章节一" }]
}
],
"revision": 7,
"conflict_detection_key": "doc_1:7"
}
}"#
.into(),
),
mutation_fixtures_json: Some(
r#"{
"documents:updateContent": {
"ok": true,
"updated_at": "2026-04-18T09:45:00Z",
"revision": 8,
"conflict_detection_key": "doc_1:8"
}
}"#
.into(),
),
dev_user_id: "dev-user".into(),
dev_user_name: "开发用户".into(),
dev_user_email: "dev@mnote.local".into(),
}))
}
#[tokio::test]
async fn documents_meta_route_returns_document_metadata() {
let response = app()
.oneshot(
Request::builder()
.uri("/api/documents/meta?documentId=doc_1&workspaceId=ws_demo")
.body(Body::empty())
.expect("request"),
)
.await
.expect("response");
assert_eq!(response.status(), StatusCode::OK);
let body = to_bytes(response.into_body(), usize::MAX)
.await
.expect("body");
let payload: Value = serde_json::from_slice(&body).expect("json");
assert_eq!(payload["result"]["id"], "doc_1");
assert_eq!(payload["result"]["workspace_id"], "ws_demo");
assert_eq!(payload["result"]["title"], "服务端页面");
assert_eq!(payload["result"]["show_structure"], true);
}
#[tokio::test]
async fn documents_content_route_returns_page_subtree() {
let response = app()
.oneshot(
Request::builder()
.uri("/api/documents/content?documentId=doc_1")
.body(Body::empty())
.expect("request"),
)
.await
.expect("response");
assert_eq!(response.status(), StatusCode::OK);
let body = to_bytes(response.into_body(), usize::MAX)
.await
.expect("body");
let payload: Value = serde_json::from_slice(&body).expect("json");
assert_eq!(payload["result"]["revision"], 7);
assert_eq!(payload["result"]["pageSubtree"]["rootNodeId"], "doc_1");
assert_eq!(
payload["result"]["pageSubtree"]["outline"][0]["title"],
"章节一"
);
}
#[tokio::test]
async fn documents_save_route_executes_documents_save_command() {
let response = app()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/documents/save")
.header("content-type", "application/json")
.body(Body::from(
serde_json::json!({
"documentId": "doc_1",
"workspaceId": "ws_demo",
"revision": 7,
"conflictDetectionKey": "doc_1:7",
"content": [
{
"id": "heading_1",
"type": "heading",
"props": { "level": 1 },
"content": [{ "type": "text", "text": "章节一(已编辑)" }]
}
],
"blockCount": 1
})
.to_string(),
))
.expect("request"),
)
.await
.expect("response");
assert_eq!(response.status(), StatusCode::OK);
let body = to_bytes(response.into_body(), usize::MAX)
.await
.expect("body");
let payload: Value = serde_json::from_slice(&body).expect("json");
assert_eq!(payload["result"]["revision"], 8);
assert_eq!(payload["result"]["conflict_detection_key"], "doc_1:8");
}
}
File diff suppressed because it is too large Load Diff
+11
View File
@@ -1,6 +1,8 @@
mod bridge;
mod command_support;
mod compat;
mod documents;
mod editor;
mod health;
mod hermes;
mod kernel;
@@ -22,6 +24,15 @@ pub fn build_router(state: AppState) -> Router {
Router::new()
.route("/health", get(health::health))
.route("/tree", get(tree::tree_shell))
.route("/document-debug", get(editor::document_editor_shell))
.route("/document", get(editor::document_editor_shell))
.route("/api/documents/meta", get(documents::meta))
.route("/api/documents/content", get(documents::content))
.route("/api/documents/save", post(documents::save))
.route(
"/api/documents/runtime/transform",
post(editor::transform_runtime_snapshot),
)
.route(
"/api/tree/projections/sidebar",
get(kernel::project_tree_sidebar),
@@ -7,7 +7,9 @@ use bridge_runtime::{
RuntimeExecutionPlan, RuntimeInput, RuntimeQueryEnvelopeWire, RuntimeQueryExecutionPlan,
RuntimeSourceWire,
};
use core_protocol::{GetPageMeta, QueryEnvelope};
use serde_json::Value;
use storage_convex_bridge::{build_query_request, BridgeContext};
pub fn resolve_effective_workspace_id(
context: &RequestContext,
@@ -77,6 +79,31 @@ pub fn runtime_context(
}
}
fn storage_context(
context: &RequestContext,
effective_workspace_id: Option<&str>,
) -> BridgeContext {
BridgeContext {
deployment_id: context.workspace.deployment_id.clone(),
project_id: context.workspace.project_id.clone(),
request_id: context.trace.request_id.clone(),
trace_id: context.trace.trace_id.clone(),
actor_type: context.auth.actor_type.clone(),
actor_id: context.auth.actor_id.clone(),
session_id: context.auth.session_id.clone(),
workspace_id: effective_workspace_id
.map(ToOwned::to_owned)
.or_else(|| context.workspace.workspace_id.clone()),
tenant_id: context.workspace.tenant_id.clone(),
auth_token: context.auth.authorization.clone(),
source_channel: context.source.channel.clone(),
source_client: context.source.client.clone(),
idempotency_key: context.source.idempotency_key.clone(),
validate_only: false,
dry_run: false,
}
}
pub fn build_runtime_query_plan(
context: &RequestContext,
effective_workspace_id: Option<&str>,
@@ -96,6 +123,34 @@ pub fn build_runtime_query_plan(
Ok(plan)
}
pub fn build_documents_meta_query_plan(
context: &RequestContext,
effective_workspace_id: Option<&str>,
document_id: &str,
) -> Result<RuntimeQueryExecutionPlan, WebError> {
let query = QueryEnvelope {
name: "documents.meta.get".into(),
payload: GetPageMeta {
page_id: document_id.to_string(),
workspace_id: effective_workspace_id.map(ToOwned::to_owned),
},
};
let request = build_query_request(&storage_context(context, effective_workspace_id), &query)
.map_err(|error| WebError::bad_request(error.message).with_context(context))?;
Ok(RuntimeQueryExecutionPlan {
query_name: query.name,
function_name: request.function_name,
workspace_id: request.workspace_id,
request_id: request.request_id,
trace_id: request.trace_id,
actor_id: request.actor_id,
payload_json: request.payload_json,
args_json: serde_json::json!({
"id": document_id,
}),
})
}
pub async fn fetch_query_data_via_convex(
config: &AppConfig,
context: &RequestContext,
@@ -106,6 +161,16 @@ pub async fn fetch_query_data_via_convex(
execute_convex_query_plan(config, context, &plan).await
}
pub async fn fetch_documents_meta_via_convex(
config: &AppConfig,
context: &RequestContext,
effective_workspace_id: Option<&str>,
document_id: &str,
) -> Result<Value, WebError> {
let plan = build_documents_meta_query_plan(context, effective_workspace_id, document_id)?;
execute_convex_query_plan(config, context, &plan).await
}
pub fn execute_runtime_query_against_data(
context: &RequestContext,
effective_workspace_id: Option<&str>,