From eb1cc6104259776a99f456ef147e0f5d9103154c Mon Sep 17 00:00:00 2001 From: Andras Schmelczer Date: Sun, 1 Jun 2025 09:50:52 +0100 Subject: [PATCH 01/29] Implement cursor broadcasting backend --- .gitignore | 35 +++-- backend/Cargo.lock | 35 +++++ backend/sync_server/Cargo.toml | 1 + backend/sync_server/src/app_state.rs | 13 +- backend/sync_server/src/app_state/cursors.rs | 124 +++++++++++++++ backend/sync_server/src/app_state/database.rs | 28 +++- .../src/app_state/database/models.rs | 4 +- .../sync_server/src/app_state/websocket.rs | 3 + .../app_state/{ => websocket}/broadcasts.rs | 36 +++-- .../src/app_state/websocket/models.rs | 76 +++++++++ .../src/app_state/websocket/utils.rs | 74 +++++++++ .../sync_server/src/config/database_config.rs | 14 +- backend/sync_server/src/consts.rs | 7 +- backend/sync_server/src/server.rs | 2 +- .../sync_server/src/server/create_document.rs | 29 +--- .../sync_server/src/server/delete_document.rs | 16 +- backend/sync_server/src/server/requests.rs | 7 +- .../sync_server/src/server/update_document.rs | 29 +--- backend/sync_server/src/server/websocket.rs | 146 ++++++++---------- 19 files changed, 488 insertions(+), 191 deletions(-) create mode 100644 backend/sync_server/src/app_state/cursors.rs create mode 100644 backend/sync_server/src/app_state/websocket.rs rename backend/sync_server/src/app_state/{ => websocket}/broadcasts.rs (53%) create mode 100644 backend/sync_server/src/app_state/websocket/models.rs create mode 100644 backend/sync_server/src/app_state/websocket/utils.rs diff --git a/.gitignore b/.gitignore index a91ed90b..384c91eb 100644 --- a/.gitignore +++ b/.gitignore @@ -1,17 +1,18 @@ -# npm -node_modules - -# Exclude macOS Finder (System Explorer) View States -.DS_Store - -# Rust build folder -backend/target - -frontend/*/dist - -backend/db.sqlite3* -backend/databases - -*.log - -*.sqlx +# npm +node_modules + +# Exclude macOS Finder (System Explorer) View States +.DS_Store + +# Rust build folder +backend/target + +# Frontend build folders +frontend/*/dist + +backend/db.sqlite3* +backend/databases +backend/sync_server/bindings/*.ts + +*.log +*.sqlx diff --git a/backend/Cargo.lock b/backend/Cargo.lock index adbb5d20..2f009e1d 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -2589,6 +2589,7 @@ dependencies = [ "tower-http", "tracing", "tracing-subscriber", + "ts-rs", "uuid", ] @@ -2622,6 +2623,15 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "termcolor" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755" +dependencies = [ + "winapi-util", +] + [[package]] name = "test-case" version = "3.3.1" @@ -2920,6 +2930,31 @@ dependencies = [ "tracing-log", ] +[[package]] +name = "ts-rs" +version = "10.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e640d9b0964e9d39df633548591090ab92f7a4567bc31d3891af23471a3365c6" +dependencies = [ + "chrono", + "lazy_static", + "thiserror 2.0.12", + "ts-rs-macros", + "uuid", +] + +[[package]] +name = "ts-rs-macros" +version = "10.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e9d8656589772eeec2cf7a8264d9cda40fb28b9bc53118ceb9e8c07f8f38730" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.90", + "termcolor", +] + [[package]] name = "tungstenite" version = "0.24.0" diff --git a/backend/sync_server/Cargo.toml b/backend/sync_server/Cargo.toml index a483ed5c..e593dc3b 100644 --- a/backend/sync_server/Cargo.toml +++ b/backend/sync_server/Cargo.toml @@ -37,6 +37,7 @@ futures = "0.3.31" serde_json = "1.0.140" clap-verbosity-flag = "3.0.3" bimap = "0.6.3" +ts-rs = { version = "10.1", features = ["uuid-impl", "chrono-impl"] } [lints] workspace = true diff --git a/backend/sync_server/src/app_state.rs b/backend/sync_server/src/app_state.rs index 1cad9149..a61467d5 100644 --- a/backend/sync_server/src/app_state.rs +++ b/backend/sync_server/src/app_state.rs @@ -1,11 +1,13 @@ -pub mod broadcasts; +pub mod cursors; pub mod database; +pub mod websocket; use std::ffi::OsString; use anyhow::Result; -use broadcasts::Broadcasts; +use cursors::Cursors; use database::Database; +use websocket::broadcasts::Broadcasts; use crate::{config::Config, consts::DEFAULT_CONFIG_PATH}; @@ -13,6 +15,7 @@ use crate::{config::Config, consts::DEFAULT_CONFIG_PATH}; pub struct AppState { pub config: Config, pub database: Database, + pub cursors: Cursors, pub broadcasts: Broadcasts, } @@ -22,12 +25,16 @@ impl AppState { let path = std::path::PathBuf::from(config_path); let config = Config::read_or_create(&path).await?; - let database = Database::try_new(&config.database).await?; let broadcasts = Broadcasts::new(&config.server); + let database = Database::try_new(&config.database, &broadcasts).await?; + let cursors: Cursors = Cursors::new(&config.database, &broadcasts); + + Cursors::start_background_task(cursors.clone()); Ok(Self { config, database, + cursors, broadcasts, }) } diff --git a/backend/sync_server/src/app_state/cursors.rs b/backend/sync_server/src/app_state/cursors.rs new file mode 100644 index 00000000..851566a7 --- /dev/null +++ b/backend/sync_server/src/app_state/cursors.rs @@ -0,0 +1,124 @@ +use core::time::Duration; +use std::{collections::HashMap, sync::Arc}; + +use chrono::TimeDelta; +use sqlx::types::chrono::Utc; +use tokio::sync::Mutex; + +use super::{ + database::models::{DeviceId, VaultId}, + websocket::{ + broadcasts::Broadcasts, + models::{ + ClientCursors, CursorPositionFromServer, WebSocketServerMessage, + WebSocketServerMessageWithOrigin, + }, + }, +}; +use crate::config::database_config::DatabaseConfig; + +const BACKGROUND_TASK_INTERVAL: Duration = Duration::from_secs(1); + +#[derive(Clone, Debug)] +pub struct Cursors { + config: DatabaseConfig, + broadcasts: Broadcasts, + vault_to_cursors: Arc>>>, +} + +impl Cursors { + pub fn new(config: &DatabaseConfig, broadcasts: &Broadcasts) -> Self { + Self { + config: config.clone(), + broadcasts: broadcasts.clone(), + vault_to_cursors: Arc::new(Mutex::new(HashMap::new())), + } + } + + pub async fn update_cursors( + &self, + vault_id: VaultId, + device_id: &DeviceId, + document_to_cursors: HashMap>, + ) { + let mut vault_to_cursors = self.vault_to_cursors.lock().await; + + let all_device_cursors = vault_to_cursors.entry(vault_id).or_insert_with(Vec::new); + + all_device_cursors.retain(|c| &c.client_cursors.device_id != device_id); + all_device_cursors.push(ClientCursorsWithTimeToLive::new(ClientCursors { + device_id: device_id.to_string(), + cursors: document_to_cursors, + })); + } + + pub async fn get_cursors(&self, vault_id: &VaultId) -> Vec { + let vault_to_cursors = self.vault_to_cursors.lock().await; + vault_to_cursors + .get(vault_id) + .map(|cursors| { + cursors + .iter() + .cloned() + .map(|with_ttl| with_ttl.client_cursors) + .collect::>() + }) + .unwrap_or_default() + } + + pub fn start_background_task(self) { + tokio::spawn(async move { + self.run_backround_task().await; + }); + } + + async fn run_backround_task(&self) { + loop { + self.remove_expired_cursors().await; + self.broadcast_cursors().await; + tokio::time::sleep(BACKGROUND_TASK_INTERVAL).await; + } + } + + async fn remove_expired_cursors(&self) { + let mut vault_to_cursors = self.vault_to_cursors.lock().await; + + for (_vault_id, cursors) in vault_to_cursors.iter_mut() { + cursors.retain(|cursor| !cursor.is_expired(self.config.cursor_timeout)); + } + } + + async fn broadcast_cursors(&self) { + let vault_to_cursors = self.vault_to_cursors.lock().await; + + for (vault_id, cursors) in vault_to_cursors.iter() { + self.broadcasts + .send_document_update( + vault_id.clone(), + WebSocketServerMessageWithOrigin::new(WebSocketServerMessage::CursorPositions( + CursorPositionFromServer { + clients: cursors.iter().map(|c| c.client_cursors.clone()).collect(), + }, + )), + ) + .await; + } + } +} + +#[derive(Clone, Debug)] +struct ClientCursorsWithTimeToLive { + client_cursors: ClientCursors, + last_updated: chrono::DateTime, +} + +impl ClientCursorsWithTimeToLive { + fn new(client_cursors: ClientCursors) -> Self { + Self { + client_cursors, + last_updated: Utc::now(), + } + } + + pub fn is_expired(&self, ttl: TimeDelta) -> bool { Utc::now() - self.last_updated > ttl } +} diff --git a/backend/sync_server/src/app_state/database.rs b/backend/sync_server/src/app_state/database.rs index 2ef03ba1..f8940140 100644 --- a/backend/sync_server/src/app_state/database.rs +++ b/backend/sync_server/src/app_state/database.rs @@ -6,23 +6,29 @@ use models::{ DocumentId, DocumentVersionWithoutContent, StoredDocumentVersion, VaultId, VaultUpdateId, }; use sqlx::{sqlite::SqliteConnectOptions, types::chrono::Utc}; + pub mod models; use sqlx::{Pool, Sqlite, sqlite::SqlitePoolOptions}; use tokio::sync::Mutex; use uuid::fmt::Hyphenated; +use super::websocket::{ + broadcasts::Broadcasts, + models::{WebSocketServerMessage, WebSocketServerMessageWithOrigin, WebSocketVaultUpdate}, +}; use crate::config::database_config::DatabaseConfig; #[derive(Clone, Debug)] pub struct Database { config: DatabaseConfig, + broadcasts: Broadcasts, connection_pools: Arc>>>, } pub type Transaction<'a> = sqlx::Transaction<'a, Sqlite>; impl Database { - pub async fn try_new(config: &DatabaseConfig) -> Result { + pub async fn try_new(config: &DatabaseConfig, broadcasts: &Broadcasts) -> Result { tokio::fs::create_dir_all(&config.databases_directory_path) .await .with_context(|| { @@ -55,6 +61,7 @@ impl Database { Ok(Self { config: config.clone(), connection_pools: Arc::new(Mutex::new(connection_pools)), + broadcasts: broadcasts.clone(), }) } @@ -362,7 +369,7 @@ impl Database { pub async fn insert_document_version( &self, - vault: &VaultId, + vault_id: &VaultId, version: &StoredDocumentVersion, transaction: Option<&mut Transaction<'_>>, ) -> Result<()> { @@ -394,10 +401,25 @@ impl Database { if let Some(transaction) = transaction { query.execute(&mut **transaction).await } else { - query.execute(&self.get_connection_pool(vault).await?).await + query + .execute(&self.get_connection_pool(vault_id).await?) + .await } .context("Cannot insert document version")?; + self.broadcasts + .send_document_update( + vault_id.clone(), + WebSocketServerMessageWithOrigin::with_origin( + version.device_id.clone(), + WebSocketServerMessage::VaultUpdate(WebSocketVaultUpdate { + documents: vec![version.clone().into()], + is_initial_sync: false, + }), + ), + ) + .await; + Ok(()) } } diff --git a/backend/sync_server/src/app_state/database/models.rs b/backend/sync_server/src/app_state/database/models.rs index 62ba66b6..197d96d7 100644 --- a/backend/sync_server/src/app_state/database/models.rs +++ b/backend/sync_server/src/app_state/database/models.rs @@ -2,9 +2,11 @@ use chrono::{DateTime, Utc}; use schemars::JsonSchema; use serde::Serialize; use sync_lib::bytes_to_base64; +use ts_rs::TS; pub type VaultId = String; pub type VaultUpdateId = i64; + pub type DocumentId = uuid::Uuid; pub type UserId = String; pub type DeviceId = String; @@ -25,7 +27,7 @@ impl PartialEq for StoredDocumentVersion { fn eq(&self, other: &Self) -> bool { self.vault_update_id == other.vault_update_id } } -#[derive(Debug, Clone, Serialize, JsonSchema)] +#[derive(TS, Debug, Clone, Serialize, JsonSchema)] #[serde(rename_all = "camelCase")] pub struct DocumentVersionWithoutContent { pub vault_update_id: VaultUpdateId, diff --git a/backend/sync_server/src/app_state/websocket.rs b/backend/sync_server/src/app_state/websocket.rs new file mode 100644 index 00000000..b945606f --- /dev/null +++ b/backend/sync_server/src/app_state/websocket.rs @@ -0,0 +1,3 @@ +pub mod broadcasts; +pub mod models; +pub mod utils; diff --git a/backend/sync_server/src/app_state/broadcasts.rs b/backend/sync_server/src/app_state/websocket/broadcasts.rs similarity index 53% rename from backend/sync_server/src/app_state/broadcasts.rs rename to backend/sync_server/src/app_state/websocket/broadcasts.rs index f71886cf..cef6ee6a 100644 --- a/backend/sync_server/src/app_state/broadcasts.rs +++ b/backend/sync_server/src/app_state/websocket/broadcasts.rs @@ -3,19 +3,15 @@ use std::{collections::HashMap, sync::Arc}; use anyhow::Context; use tokio::sync::{Mutex, broadcast}; -use super::database::models::{DeviceId, DocumentVersionWithoutContent, VaultId}; -use crate::{config::server_config::ServerConfig, errors::server_error}; +use super::models::WebSocketServerMessageWithOrigin; +use crate::{ + app_state::database::models::VaultId, config::server_config::ServerConfig, errors::server_error, +}; #[derive(Debug, Clone)] pub struct Broadcasts { max_clients_per_vault: usize, - tx: Arc>>>, -} - -#[derive(Debug, Clone)] -pub struct VaultUpdate { - pub origin_device_id: Option, - pub document: DocumentVersionWithoutContent, + tx: Arc>>>, } impl Broadcasts { @@ -26,20 +22,27 @@ impl Broadcasts { } } - pub async fn get_receiver(&self, vault: VaultId) -> broadcast::Receiver { + pub async fn get_receiver( + &self, + vault: VaultId, + ) -> broadcast::Receiver { let tx = self.get_or_create(vault).await; tx.subscribe() } - /// Sent a document update to all clients subscribed to the vault. - /// We ignore & log failures. - pub async fn send(&self, vault: VaultId, document: VaultUpdate) { + /// Notify all clients (who are subscribed to the vault) about an update. + /// We only log failures. + pub async fn send_document_update( + &self, + vault: VaultId, + document: WebSocketServerMessageWithOrigin, + ) { let tx = self.get_or_create(vault).await; let result = tx .send(document) - .context("Cannot broadcast update message to websocket listeners") + .context("Cannot broadcast server message to websocket listeners") .map_err(server_error); if result.is_err() { @@ -47,7 +50,10 @@ impl Broadcasts { } } - async fn get_or_create(&self, vault: VaultId) -> broadcast::Sender { + async fn get_or_create( + &self, + vault: VaultId, + ) -> broadcast::Sender { let mut tx = self.tx.lock().await; tx.entry(vault) diff --git a/backend/sync_server/src/app_state/websocket/models.rs b/backend/sync_server/src/app_state/websocket/models.rs new file mode 100644 index 00000000..3205ff25 --- /dev/null +++ b/backend/sync_server/src/app_state/websocket/models.rs @@ -0,0 +1,76 @@ +use std::collections::HashMap; + +use serde::{Deserialize, Serialize}; +use ts_rs::TS; + +use crate::app_state::database::models::{DeviceId, DocumentVersionWithoutContent, VaultUpdateId}; + +#[derive(TS, Deserialize, Clone, Debug)] +#[serde(rename_all = "camelCase")] +pub struct WebSocketHandshake { + pub token: String, + pub device_id: DeviceId, + pub last_seen_vault_update_id: Option, +} + +#[derive(TS, Deserialize, Clone, Debug)] +#[serde(rename_all = "camelCase")] +pub struct CursorPositionFromClient { + pub document_to_cursors: HashMap>, +} + +#[derive(TS, Serialize, Clone, Debug)] +#[serde(rename_all = "camelCase")] +pub struct ClientCursors { + pub device_id: DeviceId, + pub cursors: HashMap>, +} + +#[derive(TS, Serialize, Clone, Debug)] +#[serde(rename_all = "camelCase")] +pub struct CursorPositionFromServer { + pub clients: Vec, +} + +#[derive(TS, Serialize, Clone, Debug)] +#[serde(rename_all = "camelCase")] +pub struct WebSocketVaultUpdate { + pub documents: Vec, + pub is_initial_sync: bool, +} + +#[derive(TS, Deserialize, Clone, Debug)] +#[ts(export)] +pub enum WebSocketClientMessage { + Handshake(WebSocketHandshake), + CursorPositions(CursorPositionFromClient), +} + +#[derive(TS, Serialize, Clone, Debug)] +#[ts(export)] +pub enum WebSocketServerMessage { + VaultUpdate(WebSocketVaultUpdate), + CursorPositions(CursorPositionFromServer), +} + +#[derive(Clone, Debug)] +pub struct WebSocketServerMessageWithOrigin { + pub origin_device_id: Option, + pub message: WebSocketServerMessage, +} + +impl WebSocketServerMessageWithOrigin { + pub fn new(message: WebSocketServerMessage) -> Self { + Self { + origin_device_id: None, + message, + } + } + + pub fn with_origin(origin_device_id: DeviceId, message: WebSocketServerMessage) -> Self { + Self { + origin_device_id: Some(origin_device_id), + message, + } + } +} diff --git a/backend/sync_server/src/app_state/websocket/utils.rs b/backend/sync_server/src/app_state/websocket/utils.rs new file mode 100644 index 00000000..7c4e2c05 --- /dev/null +++ b/backend/sync_server/src/app_state/websocket/utils.rs @@ -0,0 +1,74 @@ +use anyhow::Context; +use axum::extract::ws::{Message, WebSocket}; +use futures::{sink::SinkExt, stream::SplitSink}; + +use super::models::{WebSocketClientMessage, WebSocketHandshake, WebSocketServerMessage}; +use crate::{ + app_state::{ + AppState, + database::models::{DocumentVersionWithoutContent, VaultId, VaultUpdateId}, + }, + errors::{SyncServerError, server_error, unauthenticated_error}, + server::auth::auth, +}; + +pub fn get_handshake( + state: &AppState, + vault_id: &VaultId, + message: Message, +) -> Result { + if let Message::Text(message) = message { + let message: WebSocketClientMessage = serde_json::from_str(&message) + .context("Failed to parse message") + .map_err(server_error)?; + + match message { + WebSocketClientMessage::Handshake(handshake) => { + auth(state, handshake.token.trim(), vault_id)?; + Ok(handshake) + } + WebSocketClientMessage::CursorPositions(_) => Err(unauthenticated_error( + anyhow::anyhow!("Expected a handshake message"), + )), + } + } else { + Err(unauthenticated_error(anyhow::anyhow!( + "Failed to authenticate due to invalid message" + ))) + } +} + +pub async fn get_unseen_documents( + state: &AppState, + vault_id: &VaultId, + last_seen_vault_update_id: Option, +) -> Result, SyncServerError> { + if let Some(update_id) = last_seen_vault_update_id { + state + .database + .get_latest_documents_since(vault_id, update_id, None) + .await + .map_err(server_error) + } else { + state + .database + .get_latest_documents(vault_id, None) + .await + .map_err(server_error) + } +} + +pub async fn send_update_over_websocket( + update: &WebSocketServerMessage, + sender: &mut SplitSink, +) -> Result<(), SyncServerError> { + let serialized_update = serde_json::to_string(update) + .context("Failed to serialize update") + .map_err(server_error)?; + + sender + .send(Message::Text(serialized_update)) + .await + .context("Failed to send message over websocket") + .map_err(server_error) +} diff --git a/backend/sync_server/src/config/database_config.rs b/backend/sync_server/src/config/database_config.rs index ef26a09d..6f91e19c 100644 --- a/backend/sync_server/src/config/database_config.rs +++ b/backend/sync_server/src/config/database_config.rs @@ -1,9 +1,12 @@ use std::path::PathBuf; +use chrono::TimeDelta; use log::debug; use serde::{Deserialize, Serialize}; -use crate::consts::{DEFAULT_DATABASES_DIRECTORY_PATH, DEFAULT_MAX_CONNECTIONS_PER_VAULT}; +use crate::consts::{ + DEFAULT_CURSOR_TIMEOUT, DEFAULT_DATABASES_DIRECTORY_PATH, DEFAULT_MAX_CONNECTIONS_PER_VAULT, +}; #[derive(Debug, Deserialize, Serialize, Clone)] pub struct DatabaseConfig { @@ -12,6 +15,9 @@ pub struct DatabaseConfig { #[serde(default = "default_max_connections_per_vault")] pub max_connections_per_vault: u32, + + #[serde(default = "default_cursor_timeout")] + pub cursor_timeout: TimeDelta, } fn default_databases_directory_path() -> PathBuf { @@ -24,11 +30,17 @@ fn default_max_connections_per_vault() -> u32 { DEFAULT_MAX_CONNECTIONS_PER_VAULT } +fn default_cursor_timeout() -> TimeDelta { + debug!("Using default cursor timeout: {DEFAULT_CURSOR_TIMEOUT}"); + DEFAULT_CURSOR_TIMEOUT +} + impl Default for DatabaseConfig { fn default() -> Self { Self { databases_directory_path: default_databases_directory_path(), max_connections_per_vault: default_max_connections_per_vault(), + cursor_timeout: default_cursor_timeout(), } } } diff --git a/backend/sync_server/src/consts.rs b/backend/sync_server/src/consts.rs index 57fb2559..03d5f4c2 100644 --- a/backend/sync_server/src/consts.rs +++ b/backend/sync_server/src/consts.rs @@ -1,8 +1,13 @@ +use chrono::TimeDelta; + pub const DEFAULT_CONFIG_PATH: &str = "config.yml"; + pub const DEFAULT_DATABASES_DIRECTORY_PATH: &str = "databases"; +pub const DEFAULT_MAX_CONNECTIONS_PER_VAULT: u32 = 12; +pub const DEFAULT_CURSOR_TIMEOUT: TimeDelta = TimeDelta::seconds(60); + pub const DEFAULT_HOST: &str = "127.0.0.1"; pub const DEFAULT_PORT: u16 = 3000; -pub const DEFAULT_MAX_CONNECTIONS_PER_VAULT: u32 = 12; pub const DEFAULT_MAX_BODY_SIZE_MB: usize = 4096; pub const DEFAULT_RESPONSE_TIMEOUT_SECONDS: u64 = 60; pub const DEFAULT_MAX_CLIENTS_PER_VAULT: usize = 256; diff --git a/backend/sync_server/src/server.rs b/backend/sync_server/src/server.rs index 0fd5fa03..3b1f7201 100644 --- a/backend/sync_server/src/server.rs +++ b/backend/sync_server/src/server.rs @@ -1,4 +1,4 @@ -mod auth; +pub mod auth; mod create_document; mod delete_document; mod device_id_header; diff --git a/backend/sync_server/src/server/create_document.rs b/backend/sync_server/src/server/create_document.rs index b9459df5..84f16d6a 100644 --- a/backend/sync_server/src/server/create_document.rs +++ b/backend/sync_server/src/server/create_document.rs @@ -17,9 +17,8 @@ use super::{ use crate::{ app_state::{ AppState, - broadcasts::VaultUpdate, database::models::{ - DeviceId, DocumentId, DocumentVersionWithoutContent, StoredDocumentVersion, VaultId, + DocumentId, DocumentVersionWithoutContent, StoredDocumentVersion, VaultId, }, }, config::user_config::User, @@ -41,7 +40,7 @@ pub struct CreateDocumentPathParams { pub async fn create_document_multipart( Path(CreateDocumentPathParams { vault_id }): Path, Extension(user): Extension, - TypedHeader(user_agent): TypedHeader, + TypedHeader(device_id): TypedHeader, State(state): State, TypedMultipart(axum_typed_multipart::TypedMultipart(request)): TypedMultipart< CreateDocumentVersionMultipart, @@ -49,12 +48,11 @@ pub async fn create_document_multipart( ) -> Result, SyncServerError> { internal_create_document( user, - user_agent, + device_id, state, vault_id, request.document_id, request.relative_path, - request.device_id, request.content.contents.to_vec(), ) .await @@ -67,7 +65,7 @@ pub async fn create_document_multipart( pub async fn create_document_json( Path(CreateDocumentPathParams { vault_id }): Path, Extension(user): Extension, - TypedHeader(user_agent): TypedHeader, + TypedHeader(device_id): TypedHeader, State(state): State, Json(request): Json, ) -> Result, SyncServerError> { @@ -77,12 +75,11 @@ pub async fn create_document_json( internal_create_document( user, - user_agent, + device_id, state, vault_id, request.document_id, request.relative_path, - request.device_id, content_bytes, ) .await @@ -91,12 +88,11 @@ pub async fn create_document_json( #[allow(clippy::too_many_arguments)] async fn internal_create_document( user: User, - user_agent: DeviceIdHeader, + device_id: DeviceIdHeader, state: AppState, vault_id: VaultId, document_id: Option, relative_path: String, - device_id: Option, content: Vec, ) -> Result, SyncServerError> { let mut transaction = state @@ -140,7 +136,7 @@ async fn internal_create_document( updated_date: chrono::Utc::now(), is_deleted: false, user_id: user.name, - device_id: user_agent.0, + device_id: device_id.0, }; state @@ -155,16 +151,5 @@ async fn internal_create_document( .context("Failed to commit successful transaction") .map_err(server_error)?; - state - .broadcasts - .send( - vault_id, - VaultUpdate { - origin_device_id: device_id, - document: new_version.clone().into(), - }, - ) - .await; - Ok(Json(new_version.into())) } diff --git a/backend/sync_server/src/server/delete_document.rs b/backend/sync_server/src/server/delete_document.rs index dbb9a0df..d27e97cd 100644 --- a/backend/sync_server/src/server/delete_document.rs +++ b/backend/sync_server/src/server/delete_document.rs @@ -12,7 +12,6 @@ use super::{device_id_header::DeviceIdHeader, requests::DeleteDocumentVersion}; use crate::{ app_state::{ AppState, - broadcasts::VaultUpdate, database::models::{ DocumentId, DocumentVersionWithoutContent, StoredDocumentVersion, VaultId, }, @@ -38,7 +37,7 @@ pub async fn delete_document( document_id, }): Path, Extension(user): Extension, - TypedHeader(user_agent): TypedHeader, + TypedHeader(device_id): TypedHeader, State(state): State, Json(request): Json, ) -> Result, SyncServerError> { @@ -69,7 +68,7 @@ pub async fn delete_document( updated_date: chrono::Utc::now(), is_deleted: true, user_id: user.name, - device_id: user_agent.0, + device_id: device_id.0, }; state @@ -84,16 +83,5 @@ pub async fn delete_document( .context("Failed to commit successful transaction") .map_err(server_error)?; - state - .broadcasts - .send( - vault_id, - VaultUpdate { - origin_device_id: request.device_id, - document: new_version.clone().into(), - }, - ) - .await; - Ok(Json(new_version.into())) } diff --git a/backend/sync_server/src/server/requests.rs b/backend/sync_server/src/server/requests.rs index 26e6a398..89820dbe 100644 --- a/backend/sync_server/src/server/requests.rs +++ b/backend/sync_server/src/server/requests.rs @@ -4,7 +4,7 @@ use axum_typed_multipart::TryFromMultipart; use schemars::JsonSchema; use serde::{self, Deserialize}; -use crate::app_state::database::models::{DeviceId, DocumentId, VaultUpdateId}; +use crate::app_state::database::models::{DocumentId, VaultUpdateId}; #[derive(Debug, Deserialize, JsonSchema)] #[serde(rename_all = "camelCase")] @@ -16,7 +16,6 @@ pub struct CreateDocumentVersion { pub document_id: Option, pub relative_path: String, pub content_base64: String, - pub device_id: Option, } #[derive(Debug, TryFromMultipart, JsonSchema)] @@ -25,7 +24,6 @@ pub struct CreateDocumentVersionMultipart { pub relative_path: String, #[form_data(limit = "unlimited")] pub content: FieldData, - pub device_id: Option, } #[derive(Debug, Deserialize, JsonSchema)] @@ -34,7 +32,6 @@ pub struct UpdateDocumentVersion { pub parent_version_id: VaultUpdateId, pub relative_path: String, pub content_base64: String, - pub device_id: Option, } #[derive(Debug, TryFromMultipart, JsonSchema)] @@ -44,12 +41,10 @@ pub struct UpdateDocumentVersionMultipart { pub relative_path: String, #[form_data(limit = "unlimited")] pub content: FieldData, - pub device_id: Option, } #[derive(Debug, Deserialize, JsonSchema)] #[serde(rename_all = "camelCase")] pub struct DeleteDocumentVersion { pub relative_path: String, - pub device_id: Option, } diff --git a/backend/sync_server/src/server/update_document.rs b/backend/sync_server/src/server/update_document.rs index 22eb38b0..a784dad4 100644 --- a/backend/sync_server/src/server/update_document.rs +++ b/backend/sync_server/src/server/update_document.rs @@ -19,8 +19,7 @@ use super::{ use crate::{ app_state::{ AppState, - broadcasts::VaultUpdate, - database::models::{DeviceId, DocumentId, StoredDocumentVersion, VaultId, VaultUpdateId}, + database::models::{DocumentId, StoredDocumentVersion, VaultId, VaultUpdateId}, }, config::user_config::User, errors::{SyncServerError, client_error, not_found_error, server_error}, @@ -43,7 +42,7 @@ pub async fn update_document_multipart( document_id, }): Path, Extension(user): Extension, - TypedHeader(user_agent): TypedHeader, + TypedHeader(device_id): TypedHeader, State(state): State, TypedMultipart(axum_typed_multipart::TypedMultipart(request)): TypedMultipart< UpdateDocumentVersionMultipart, @@ -51,13 +50,12 @@ pub async fn update_document_multipart( ) -> Result, SyncServerError> { internal_update_document( user, - user_agent, + device_id, state, vault_id, document_id, request.parent_version_id, request.relative_path, - request.device_id, request.content.contents.to_vec(), ) .await @@ -70,7 +68,7 @@ pub async fn update_document_json( document_id, }): Path, Extension(user): Extension, - TypedHeader(user_agent): TypedHeader, + TypedHeader(device_id): TypedHeader, State(state): State, Json(request): Json, ) -> Result, SyncServerError> { @@ -80,13 +78,12 @@ pub async fn update_document_json( internal_update_document( user, - user_agent, + device_id, state, vault_id, document_id, request.parent_version_id, request.relative_path, - request.device_id, content_bytes, ) .await @@ -95,13 +92,12 @@ pub async fn update_document_json( #[allow(clippy::too_many_arguments, clippy::too_many_lines)] async fn internal_update_document( user: User, - user_agent: DeviceIdHeader, + device_id: DeviceIdHeader, state: AppState, vault_id: VaultId, document_id: DocumentId, parent_version_id: VaultUpdateId, relative_path: String, - device_id: Option, content: Vec, ) -> Result, SyncServerError> { // No need for a transaction as document versions are immutable @@ -215,7 +211,7 @@ async fn internal_update_document( updated_date: chrono::Utc::now(), is_deleted: false, user_id: user.name, - device_id: user_agent.0, + device_id: device_id.0, }; state @@ -230,17 +226,6 @@ async fn internal_update_document( .context("Failed to commit successful transaction") .map_err(server_error)?; - state - .broadcasts - .send( - vault_id, - VaultUpdate { - origin_device_id: device_id, - document: new_version.clone().into(), - }, - ) - .await; - Ok(Json(if is_different_from_request_content { DocumentUpdateResponse::MergingUpdate(new_version.into()) } else { diff --git a/backend/sync_server/src/server/websocket.rs b/backend/sync_server/src/server/websocket.rs index 2517fe88..ea0e7fad 100644 --- a/backend/sync_server/src/server/websocket.rs +++ b/backend/sync_server/src/server/websocket.rs @@ -6,64 +6,52 @@ use axum::{ }, response::Response, }; -use futures::{ - sink::SinkExt, - stream::{SplitSink, StreamExt}, -}; +use futures::stream::StreamExt; use log::{error, info, warn}; use schemars::JsonSchema; -use serde::{Deserialize, Serialize}; +use serde::Deserialize; -use super::auth::auth; use crate::{ app_state::{ AppState, - database::models::{DeviceId, DocumentVersionWithoutContent, VaultId, VaultUpdateId}, + database::models::VaultId, + websocket::{ + models::{ + CursorPositionFromServer, WebSocketClientMessage, WebSocketServerMessage, + WebSocketVaultUpdate, + }, + utils::{get_handshake, get_unseen_documents, send_update_over_websocket}, + }, }, - errors::{SyncServerError, server_error, unauthenticated_error}, + errors::{SyncServerError, client_error, server_error, unauthenticated_error}, utils::normalize::normalize, }; // This is required for aide to infer the path parameter types and names #[derive(Deserialize, JsonSchema)] -pub struct WebsocketPathParams { +pub struct WebSocketPathParams { #[serde(deserialize_with = "normalize")] vault_id: VaultId, } pub async fn websocket_handler( ws: WebSocketUpgrade, - Path(WebsocketPathParams { vault_id }): Path, + Path(WebSocketPathParams { vault_id }): Path, State(state): State, ) -> Result { Ok(ws.on_upgrade(move |socket| websocket_wrapped(state, socket, vault_id))) } async fn websocket_wrapped(state: AppState, stream: WebSocket, vault_id: VaultId) { - info!("Websocket connection opened on vault '{vault_id}'"); + info!("WebSocket connection opened on vault '{vault_id}'"); let result = websocket(state, stream, vault_id.clone()).await; if let Err(err) = result { - error!("Websocket connection error on vault '{vault_id}': {err}"); + error!("WebSocket connection error on vault '{vault_id}': {err}"); } - warn!("Websocket connection closed on vault '{vault_id}'"); -} - -#[derive(Deserialize)] -#[serde(rename_all = "camelCase")] -struct WebsocketHandshake { - pub token: String, - pub device_id: DeviceId, - pub last_seen_vault_update_id: Option, -} - -#[derive(Serialize)] -#[serde(rename_all = "camelCase")] -struct WebsocketVaultUpdate { - pub documents: Vec, - pub is_initial_sync: bool, + warn!("WebSocket connection closed on vault '{vault_id}'"); } async fn websocket( @@ -73,68 +61,71 @@ async fn websocket( ) -> Result<(), SyncServerError> { let (mut sender, mut receiver) = stream.split(); - let handshake = if let Some(Ok(Message::Text(token))) = receiver.next().await { - let handshake: WebsocketHandshake = serde_json::from_str(&token) - .context("Failed to parse token") - .map_err(server_error)?; - - auth(&state, handshake.token.trim(), &vault_id)?; - - handshake + let handshake = if let Some(Ok(message)) = receiver.next().await { + get_handshake(&state, &vault_id, message)? } else { return Err(unauthenticated_error(anyhow::anyhow!( - "Failed to authenticate" + "Failed to authenticate due to invalid message" ))); }; let mut rx = state.broadcasts.get_receiver(vault_id.clone()).await; - let documents = if let Some(update_id) = handshake.last_seen_vault_update_id { - state - .database - .get_latest_documents_since(&vault_id, update_id, None) - .await - .map_err(server_error) - } else { - state - .database - .get_latest_documents(&vault_id, None) - .await - .map_err(server_error) - }?; - send_update_over_websocket( - &WebsocketVaultUpdate { - documents, + &WebSocketServerMessage::VaultUpdate(WebSocketVaultUpdate { + documents: get_unseen_documents(&state, &vault_id, handshake.last_seen_vault_update_id) + .await?, is_initial_sync: true, - }, + }), &mut sender, ) .await?; + send_update_over_websocket( + &WebSocketServerMessage::CursorPositions(CursorPositionFromServer { + clients: state.cursors.get_cursors(&vault_id).await, + }), + &mut sender, + ) + .await?; + + let device_id = handshake.device_id.clone(); let mut send_task = tokio::spawn(async move { while let Ok(update) = rx.recv().await { - if Some(&handshake.device_id) == update.origin_device_id.as_ref() { + if Some(&device_id) == update.origin_device_id.as_ref() { continue; } - send_update_over_websocket( - &WebsocketVaultUpdate { - documents: vec![update.document], - is_initial_sync: false, - }, - &mut sender, - ) - .await?; + send_update_over_websocket(&update.message, &mut sender).await?; } Ok::<(), SyncServerError>(()) }); - let mut recv_task = - tokio::spawn( - async move { while let Some(Ok(Message::Text(_text))) = receiver.next().await {} }, - ); + let device_id = handshake.device_id.clone(); + let mut recv_task = tokio::spawn(async move { + while let Some(Ok(Message::Text(message))) = receiver.next().await { + let message: WebSocketClientMessage = serde_json::from_str(&message) + .context("Failed to parse message") + .map_err(server_error)?; + + match message { + WebSocketClientMessage::Handshake(_) => { + return Err(client_error(anyhow::anyhow!( + "Unexpected handshake message" + ))); + } + WebSocketClientMessage::CursorPositions(cursors) => { + state + .cursors + .update_cursors(vault_id.clone(), &device_id, cursors.document_to_cursors) + .await; + } + } + } + + Ok::<(), SyncServerError>(()) + }); tokio::select! { _ = &mut send_task => recv_task.abort(), @@ -143,28 +134,13 @@ async fn websocket( send_task .await - .context("Websocket send task failed") + .context("WebSocket send task failed") .map_err(server_error)??; recv_task .await - .context("Websocket receive task failed") - .map_err(server_error)?; + .context("WebSocket receive task failed") + .map_err(server_error)??; Ok(()) } - -async fn send_update_over_websocket( - update: &WebsocketVaultUpdate, - sender: &mut SplitSink, -) -> Result<(), SyncServerError> { - let serialized_update = serde_json::to_string(update) - .context("Failed to serialize update") - .map_err(server_error)?; - - sender - .send(Message::Text(serialized_update)) - .await - .context("Failed to send message over websocket") - .map_err(server_error) -} -- 2.47.2 From 2cdf2ba74e7450227aa0179d9c4ab581e0a0d594 Mon Sep 17 00:00:00 2001 From: Andras Schmelczer Date: Sun, 1 Jun 2025 09:51:06 +0100 Subject: [PATCH 02/29] Serialize Rust types to TS --- backend/sync_server/src/app_state/database/models.rs | 4 ++++ backend/sync_server/src/app_state/websocket/models.rs | 4 ++++ scripts/update-api-types.sh | 9 ++++++++- 3 files changed, 16 insertions(+), 1 deletion(-) diff --git a/backend/sync_server/src/app_state/database/models.rs b/backend/sync_server/src/app_state/database/models.rs index 197d96d7..a83202b9 100644 --- a/backend/sync_server/src/app_state/database/models.rs +++ b/backend/sync_server/src/app_state/database/models.rs @@ -30,13 +30,17 @@ impl PartialEq for StoredDocumentVersion { #[derive(TS, Debug, Clone, Serialize, JsonSchema)] #[serde(rename_all = "camelCase")] pub struct DocumentVersionWithoutContent { + #[ts(as = "i32")] pub vault_update_id: VaultUpdateId, + pub document_id: DocumentId, pub relative_path: String, pub updated_date: DateTime, pub is_deleted: bool, pub user_id: UserId, pub device_id: DeviceId, + + #[ts(as = "i32")] pub content_size: u64, } diff --git a/backend/sync_server/src/app_state/websocket/models.rs b/backend/sync_server/src/app_state/websocket/models.rs index 3205ff25..e6b1bade 100644 --- a/backend/sync_server/src/app_state/websocket/models.rs +++ b/backend/sync_server/src/app_state/websocket/models.rs @@ -10,6 +10,8 @@ use crate::app_state::database::models::{DeviceId, DocumentVersionWithoutContent pub struct WebSocketHandshake { pub token: String, pub device_id: DeviceId, + + #[ts(as = "Option")] pub last_seen_vault_update_id: Option, } @@ -40,6 +42,7 @@ pub struct WebSocketVaultUpdate { } #[derive(TS, Deserialize, Clone, Debug)] +#[serde(rename_all = "camelCase", tag = "type")] #[ts(export)] pub enum WebSocketClientMessage { Handshake(WebSocketHandshake), @@ -47,6 +50,7 @@ pub enum WebSocketClientMessage { } #[derive(TS, Serialize, Clone, Debug)] +#[serde(rename_all = "camelCase", tag = "type")] #[ts(export)] pub enum WebSocketServerMessage { VaultUpdate(WebSocketVaultUpdate), diff --git a/scripts/update-api-types.sh b/scripts/update-api-types.sh index d9f39566..6fc7f6cd 100755 --- a/scripts/update-api-types.sh +++ b/scripts/update-api-types.sh @@ -4,5 +4,12 @@ set -e ./scripts/utils/wait-for-server.sh +rm -rf backend/sync_server/bindings +cd backend +cargo test export_bindings +cd - + +cp -r backend/sync_server/bindings/* frontend/sync-client/src/services/types/ + npm install -g openapi-typescript -openapi-typescript http://localhost:3000/api.json --output frontend/sync-client/src/services/types.ts +openapi-typescript http://localhost:3000/api.json --output frontend/sync-client/src/services/types/http-api.ts -- 2.47.2 From 22cafda53feca036cb29582c460a39889b4eeef1 Mon Sep 17 00:00:00 2001 From: Andras Schmelczer Date: Sun, 1 Jun 2025 09:51:33 +0100 Subject: [PATCH 03/29] Formatting --- frontend/obsidian-plugin/src/vault-link-plugin.ts | 2 +- .../src/views/status-description/status-description.ts | 2 +- frontend/sync-client/src/utils/create-promise.ts | 4 ++++ frontend/sync-client/src/utils/locks.ts | 2 +- 4 files changed, 7 insertions(+), 3 deletions(-) diff --git a/frontend/obsidian-plugin/src/vault-link-plugin.ts b/frontend/obsidian-plugin/src/vault-link-plugin.ts index e889bf9b..1e79be35 100644 --- a/frontend/obsidian-plugin/src/vault-link-plugin.ts +++ b/frontend/obsidian-plugin/src/vault-link-plugin.ts @@ -18,7 +18,7 @@ import { registerConsoleForLogging } from "./utils/register-console-for-logging" import { updateEditorStatusDisplay } from "./views/editor-sync-line/editor-sync-line"; export default class VaultLinkPlugin extends Plugin { - private readonly disposables: (() => void)[] = []; + private readonly disposables: (() => unknown)[] = []; private settingsTab: SyncSettingsTab | undefined; private client!: SyncClient; private readonly rateLimitedUpdatesPerFile = new Map< diff --git a/frontend/obsidian-plugin/src/views/status-description/status-description.ts b/frontend/obsidian-plugin/src/views/status-description/status-description.ts index 6d5ac693..3bf41759 100644 --- a/frontend/obsidian-plugin/src/views/status-description/status-description.ts +++ b/frontend/obsidian-plugin/src/views/status-description/status-description.ts @@ -11,7 +11,7 @@ export class StatusDescription { private lastRemaining: number | undefined; private lastConnectionState: NetworkConnectionStatus | undefined; - private statusChangeListeners: (() => void)[] = []; + private statusChangeListeners: (() => unknown)[] = []; public constructor(private readonly syncClient: SyncClient) { void this.updateConnectionState(); diff --git a/frontend/sync-client/src/utils/create-promise.ts b/frontend/sync-client/src/utils/create-promise.ts index 056c169c..4004ac81 100644 --- a/frontend/sync-client/src/utils/create-promise.ts +++ b/frontend/sync-client/src/utils/create-promise.ts @@ -1,3 +1,7 @@ +/** + * A type-safe utility function to create a Promise with resolve and reject functions. + * @returns A tuple containing a Promise, a resolve function, and a reject function. + */ export function createPromise(): [ Promise, (value: T) => void, diff --git a/frontend/sync-client/src/utils/locks.ts b/frontend/sync-client/src/utils/locks.ts index 542f8a88..7e75bd3d 100644 --- a/frontend/sync-client/src/utils/locks.ts +++ b/frontend/sync-client/src/utils/locks.ts @@ -5,7 +5,7 @@ import type { Logger } from "../tracing/logger"; // Locks are granted in a first-in-first-out order. export class Locks { private readonly locked = new Set(); - private readonly waiters = new Map void)[]>(); + private readonly waiters = new Map unknown)[]>(); public constructor(private readonly logger: Logger) {} -- 2.47.2 From 4657314b7287dea8c3c87775372cda96a551df5c Mon Sep 17 00:00:00 2001 From: Andras Schmelczer Date: Sun, 1 Jun 2025 09:58:53 +0100 Subject: [PATCH 04/29] Update types --- frontend/sync-client/src/services/types.ts | 655 ------------------ .../src/services/types/ClientCursors.ts | 3 + .../types/CursorPositionFromClient.ts | 3 + .../types/CursorPositionFromServer.ts | 4 + .../types/DocumentVersionWithoutContent.ts | 3 + .../services/types/WebSocketClientMessage.ts | 5 + .../src/services/types/WebSocketHandshake.ts | 3 + .../services/types/WebSocketServerMessage.ts | 5 + .../services/types/WebSocketVaultUpdate.ts | 4 + .../src/services/types/http-api.ts | 648 +++++++++++++++++ 10 files changed, 678 insertions(+), 655 deletions(-) delete mode 100644 frontend/sync-client/src/services/types.ts create mode 100644 frontend/sync-client/src/services/types/ClientCursors.ts create mode 100644 frontend/sync-client/src/services/types/CursorPositionFromClient.ts create mode 100644 frontend/sync-client/src/services/types/CursorPositionFromServer.ts create mode 100644 frontend/sync-client/src/services/types/DocumentVersionWithoutContent.ts create mode 100644 frontend/sync-client/src/services/types/WebSocketClientMessage.ts create mode 100644 frontend/sync-client/src/services/types/WebSocketHandshake.ts create mode 100644 frontend/sync-client/src/services/types/WebSocketServerMessage.ts create mode 100644 frontend/sync-client/src/services/types/WebSocketVaultUpdate.ts create mode 100644 frontend/sync-client/src/services/types/http-api.ts diff --git a/frontend/sync-client/src/services/types.ts b/frontend/sync-client/src/services/types.ts deleted file mode 100644 index 893eea70..00000000 --- a/frontend/sync-client/src/services/types.ts +++ /dev/null @@ -1,655 +0,0 @@ -/** - * This file was auto-generated by openapi-typescript. - * Do not make direct changes to the file. - */ - -export interface paths { - "/vaults/{vault_id}/documents": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - get: { - parameters: { - query?: { - since_update_id?: number | null; - }; - header?: never; - path: { - vault_id: string; - }; - cookie?: never; - }; - requestBody?: never; - responses: { - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["FetchLatestDocumentsResponse"]; - }; - }; - default: { - headers: { - [name: string]: unknown; - }; - content: { - /** @example { - * "causes": [], - * "message": "An error has occurred" - * } */ - "application/json": components["schemas"]["SerializedError"]; - }; - }; - }; - }; - put?: never; - post: { - parameters: { - query?: never; - header: { - "device-id": string; - }; - path: { - vault_id: string; - }; - cookie?: never; - }; - requestBody: { - content: { - "multipart/form-data": components["schemas"]["CreateDocumentVersionMultipart"]; - }; - }; - responses: { - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["DocumentVersionWithoutContent"]; - }; - }; - default: { - headers: { - [name: string]: unknown; - }; - content: { - /** @example { - * "causes": [], - * "message": "An error has occurred" - * } */ - "application/json": components["schemas"]["SerializedError"]; - }; - }; - }; - }; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - "/vaults/{vault_id}/documents/json": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - get?: never; - put?: never; - post: { - parameters: { - query?: never; - header: { - "device-id": string; - }; - path: { - vault_id: string; - }; - cookie?: never; - }; - requestBody: { - content: { - "application/json": components["schemas"]["CreateDocumentVersion"]; - }; - }; - responses: { - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["DocumentVersionWithoutContent"]; - }; - }; - default: { - headers: { - [name: string]: unknown; - }; - content: { - /** @example { - * "causes": [], - * "message": "An error has occurred" - * } */ - "application/json": components["schemas"]["SerializedError"]; - }; - }; - }; - }; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - "/vaults/{vault_id}/documents/{document_id}": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - get: { - parameters: { - query?: never; - header?: never; - path: { - document_id: string; - vault_id: string; - }; - cookie?: never; - }; - requestBody?: never; - responses: { - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["DocumentVersion"]; - }; - }; - default: { - headers: { - [name: string]: unknown; - }; - content: { - /** @example { - * "causes": [], - * "message": "An error has occurred" - * } */ - "application/json": components["schemas"]["SerializedError"]; - }; - }; - }; - }; - put: { - parameters: { - query?: never; - header: { - "device-id": string; - }; - path: { - document_id: string; - vault_id: string; - }; - cookie?: never; - }; - requestBody: { - content: { - "multipart/form-data": components["schemas"]["UpdateDocumentVersionMultipart"]; - }; - }; - responses: { - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["DocumentUpdateResponse"]; - }; - }; - default: { - headers: { - [name: string]: unknown; - }; - content: { - /** @example { - * "causes": [], - * "message": "An error has occurred" - * } */ - "application/json": components["schemas"]["SerializedError"]; - }; - }; - }; - }; - post?: never; - delete: { - parameters: { - query?: never; - header: { - "device-id": string; - }; - path: { - document_id: string; - vault_id: string; - }; - cookie?: never; - }; - requestBody: { - content: { - "application/json": components["schemas"]["DeleteDocumentVersion"]; - }; - }; - responses: { - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["DocumentVersionWithoutContent"]; - }; - }; - default: { - headers: { - [name: string]: unknown; - }; - content: { - /** @example { - * "causes": [], - * "message": "An error has occurred" - * } */ - "application/json": components["schemas"]["SerializedError"]; - }; - }; - }; - }; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - "/vaults/{vault_id}/documents/{document_id}/json": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - get?: never; - put: { - parameters: { - query?: never; - header: { - "device-id": string; - }; - path: { - document_id: string; - vault_id: string; - }; - cookie?: never; - }; - requestBody: { - content: { - "application/json": components["schemas"]["UpdateDocumentVersion"]; - }; - }; - responses: { - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["DocumentUpdateResponse"]; - }; - }; - default: { - headers: { - [name: string]: unknown; - }; - content: { - /** @example { - * "causes": [], - * "message": "An error has occurred" - * } */ - "application/json": components["schemas"]["SerializedError"]; - }; - }; - }; - }; - post?: never; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - "/vaults/{vault_id}/documents/{document_id}/versions/{version_id}": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - get?: never; - put: { - parameters: { - query?: never; - header?: never; - path: { - document_id: string; - vault_id: string; - vault_update_id: number; - }; - cookie?: never; - }; - requestBody?: never; - responses: { - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["DocumentVersion"]; - }; - }; - default: { - headers: { - [name: string]: unknown; - }; - content: { - /** @example { - * "causes": [], - * "message": "An error has occurred" - * } */ - "application/json": components["schemas"]["SerializedError"]; - }; - }; - }; - }; - post?: never; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - "/vaults/{vault_id}/documents/{document_id}/versions/{version_id}/content": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - get?: never; - put: { - parameters: { - query?: never; - header?: never; - path: { - document_id: string; - vault_id: string; - vault_update_id: number; - }; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description byte stream */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "application/octet-stream": unknown; - }; - }; - default: { - headers: { - [name: string]: unknown; - }; - content: { - /** @example { - * "causes": [], - * "message": "An error has occurred" - * } */ - "application/json": components["schemas"]["SerializedError"]; - }; - }; - }; - }; - post?: never; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - "/vaults/{vault_id}/ping": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - get: { - parameters: { - query?: never; - header?: { - authorization?: string; - }; - path: { - vault_id: string; - }; - cookie?: never; - }; - requestBody?: never; - responses: { - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["PingResponse"]; - }; - }; - default: { - headers: { - [name: string]: unknown; - }; - content: { - /** @example { - * "causes": [], - * "message": "An error has occurred" - * } */ - "application/json": components["schemas"]["SerializedError"]; - }; - }; - }; - }; - put?: never; - post?: never; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; -} -export type webhooks = Record; -export interface components { - schemas: { - Array_of_uint8: number[]; - CreateDocumentPathParams: { - vault_id: string; - }; - CreateDocumentVersion: { - contentBase64: string; - deviceId?: string | null; - /** - * Format: uuid - * @description The client can decide the document id (if it wishes to) in order to help with syncing. If the client does not provide a document id, the server will generate one. If the client provides a document id it must not already exist in the database. - */ - documentId?: string | null; - relativePath: string; - }; - CreateDocumentVersionMultipart: { - content: components["schemas"]["Array_of_uint8"]; - device_id?: string | null; - /** Format: uuid */ - document_id?: string | null; - relative_path: string; - }; - DeleteDocumentPathParams: { - /** Format: uuid */ - document_id: string; - vault_id: string; - }; - DeleteDocumentVersion: { - deviceId?: string | null; - relativePath: string; - }; - /** @description Response to an update document request. */ - DocumentUpdateResponse: - | { - /** Format: uint64 */ - contentSize: number; - deviceId: string; - /** Format: uuid */ - documentId: string; - isDeleted: boolean; - relativePath: string; - /** @enum {string} */ - type: "FastForwardUpdate"; - /** Format: date-time */ - updatedDate: string; - userId: string; - /** Format: int64 */ - vaultUpdateId: number; - } - | { - contentBase64: string; - deviceId: string; - /** Format: uuid */ - documentId: string; - isDeleted: boolean; - relativePath: string; - /** @enum {string} */ - type: "MergingUpdate"; - /** Format: date-time */ - updatedDate: string; - userId: string; - /** Format: int64 */ - vaultUpdateId: number; - }; - DocumentVersion: { - contentBase64: string; - deviceId: string; - /** Format: uuid */ - documentId: string; - isDeleted: boolean; - relativePath: string; - /** Format: date-time */ - updatedDate: string; - userId: string; - /** Format: int64 */ - vaultUpdateId: number; - }; - DocumentVersionWithoutContent: { - /** Format: uint64 */ - contentSize: number; - deviceId: string; - /** Format: uuid */ - documentId: string; - isDeleted: boolean; - relativePath: string; - /** Format: date-time */ - updatedDate: string; - userId: string; - /** Format: int64 */ - vaultUpdateId: number; - }; - FetchDocumentVersionContentPathParams: { - /** Format: uuid */ - document_id: string; - vault_id: string; - /** Format: int64 */ - vault_update_id: number; - }; - FetchDocumentVersionPathParams: { - /** Format: uuid */ - document_id: string; - vault_id: string; - /** Format: int64 */ - vault_update_id: number; - }; - FetchLatestDocumentVersionPathParams: { - /** Format: uuid */ - document_id: string; - vault_id: string; - }; - FetchLatestDocumentsPathParams: { - vault_id: string; - }; - /** @description Response to a fetch latest documents request. */ - FetchLatestDocumentsResponse: { - /** - * Format: int64 - * @description The update ID of the latest document in the response. - */ - lastUpdateId: number; - latestDocuments: components["schemas"]["DocumentVersionWithoutContent"][]; - }; - PingPathParams: { - vault_id: string; - }; - /** @description Response to a ping request. */ - PingResponse: { - /** @description Whether the client is authenticated based on the sent Authorization header. */ - isAuthenticated: boolean; - /** @description Semantic version of the server. */ - serverVersion: string; - }; - QueryParams: { - /** Format: int64 */ - since_update_id?: number | null; - }; - SerializedError: { - causes: string[]; - message: string; - }; - UpdateDocumentPathParams: { - /** Format: uuid */ - document_id: string; - vault_id: string; - }; - UpdateDocumentVersion: { - contentBase64: string; - deviceId?: string | null; - /** Format: int64 */ - parentVersionId: number; - relativePath: string; - }; - UpdateDocumentVersionMultipart: { - content: components["schemas"]["Array_of_uint8"]; - deviceId?: string | null; - /** Format: int64 */ - parentVersionId: number; - relativePath: string; - }; - WebsocketPathParams: { - vault_id: string; - }; - }; - responses: never; - parameters: never; - requestBodies: never; - headers: never; - pathItems: never; -} -export type $defs = Record; -export type operations = Record; diff --git a/frontend/sync-client/src/services/types/ClientCursors.ts b/frontend/sync-client/src/services/types/ClientCursors.ts new file mode 100644 index 00000000..4a91a1e7 --- /dev/null +++ b/frontend/sync-client/src/services/types/ClientCursors.ts @@ -0,0 +1,3 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type ClientCursors = { deviceId: string, cursors: { [key in string]?: Array }, }; diff --git a/frontend/sync-client/src/services/types/CursorPositionFromClient.ts b/frontend/sync-client/src/services/types/CursorPositionFromClient.ts new file mode 100644 index 00000000..5a60ec2f --- /dev/null +++ b/frontend/sync-client/src/services/types/CursorPositionFromClient.ts @@ -0,0 +1,3 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type CursorPositionFromClient = { documentToCursors: { [key in string]?: Array }, }; diff --git a/frontend/sync-client/src/services/types/CursorPositionFromServer.ts b/frontend/sync-client/src/services/types/CursorPositionFromServer.ts new file mode 100644 index 00000000..c8444892 --- /dev/null +++ b/frontend/sync-client/src/services/types/CursorPositionFromServer.ts @@ -0,0 +1,4 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { ClientCursors } from "./ClientCursors"; + +export type CursorPositionFromServer = { clients: Array, }; diff --git a/frontend/sync-client/src/services/types/DocumentVersionWithoutContent.ts b/frontend/sync-client/src/services/types/DocumentVersionWithoutContent.ts new file mode 100644 index 00000000..03be2f63 --- /dev/null +++ b/frontend/sync-client/src/services/types/DocumentVersionWithoutContent.ts @@ -0,0 +1,3 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type DocumentVersionWithoutContent = { vaultUpdateId: number, documentId: string, relativePath: string, updatedDate: string, isDeleted: boolean, userId: string, deviceId: string, contentSize: number, }; diff --git a/frontend/sync-client/src/services/types/WebSocketClientMessage.ts b/frontend/sync-client/src/services/types/WebSocketClientMessage.ts new file mode 100644 index 00000000..5765a0d0 --- /dev/null +++ b/frontend/sync-client/src/services/types/WebSocketClientMessage.ts @@ -0,0 +1,5 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { CursorPositionFromClient } from "./CursorPositionFromClient"; +import type { WebSocketHandshake } from "./WebSocketHandshake"; + +export type WebSocketClientMessage = { "type": "handshake" } & WebSocketHandshake | { "type": "cursorPositions" } & CursorPositionFromClient; diff --git a/frontend/sync-client/src/services/types/WebSocketHandshake.ts b/frontend/sync-client/src/services/types/WebSocketHandshake.ts new file mode 100644 index 00000000..85c2cf0d --- /dev/null +++ b/frontend/sync-client/src/services/types/WebSocketHandshake.ts @@ -0,0 +1,3 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type WebSocketHandshake = { token: string, deviceId: string, lastSeenVaultUpdateId: number | null, }; diff --git a/frontend/sync-client/src/services/types/WebSocketServerMessage.ts b/frontend/sync-client/src/services/types/WebSocketServerMessage.ts new file mode 100644 index 00000000..45e37358 --- /dev/null +++ b/frontend/sync-client/src/services/types/WebSocketServerMessage.ts @@ -0,0 +1,5 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { CursorPositionFromServer } from "./CursorPositionFromServer"; +import type { WebSocketVaultUpdate } from "./WebSocketVaultUpdate"; + +export type WebSocketServerMessage = { "type": "vaultUpdate" } & WebSocketVaultUpdate | { "type": "cursorPositions" } & CursorPositionFromServer; diff --git a/frontend/sync-client/src/services/types/WebSocketVaultUpdate.ts b/frontend/sync-client/src/services/types/WebSocketVaultUpdate.ts new file mode 100644 index 00000000..b627ac3c --- /dev/null +++ b/frontend/sync-client/src/services/types/WebSocketVaultUpdate.ts @@ -0,0 +1,4 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { DocumentVersionWithoutContent } from "./DocumentVersionWithoutContent"; + +export type WebSocketVaultUpdate = { documents: Array, isInitialSync: boolean, }; diff --git a/frontend/sync-client/src/services/types/http-api.ts b/frontend/sync-client/src/services/types/http-api.ts new file mode 100644 index 00000000..5f2c08f5 --- /dev/null +++ b/frontend/sync-client/src/services/types/http-api.ts @@ -0,0 +1,648 @@ +/** + * This file was auto-generated by openapi-typescript. + * Do not make direct changes to the file. + */ + +export interface paths { + "/vaults/{vault_id}/documents": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: { + parameters: { + query?: { + since_update_id?: number | null; + }; + header?: never; + path: { + vault_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["FetchLatestDocumentsResponse"]; + }; + }; + default: { + headers: { + [name: string]: unknown; + }; + content: { + /** @example { + * "causes": [], + * "message": "An error has occurred" + * } */ + "application/json": components["schemas"]["SerializedError"]; + }; + }; + }; + }; + put?: never; + post: { + parameters: { + query?: never; + header: { + "device-id": string; + }; + path: { + vault_id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "multipart/form-data": components["schemas"]["CreateDocumentVersionMultipart"]; + }; + }; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["DocumentVersionWithoutContent"]; + }; + }; + default: { + headers: { + [name: string]: unknown; + }; + content: { + /** @example { + * "causes": [], + * "message": "An error has occurred" + * } */ + "application/json": components["schemas"]["SerializedError"]; + }; + }; + }; + }; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/vaults/{vault_id}/documents/json": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: { + parameters: { + query?: never; + header: { + "device-id": string; + }; + path: { + vault_id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["CreateDocumentVersion"]; + }; + }; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["DocumentVersionWithoutContent"]; + }; + }; + default: { + headers: { + [name: string]: unknown; + }; + content: { + /** @example { + * "causes": [], + * "message": "An error has occurred" + * } */ + "application/json": components["schemas"]["SerializedError"]; + }; + }; + }; + }; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/vaults/{vault_id}/documents/{document_id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: { + parameters: { + query?: never; + header?: never; + path: { + document_id: string; + vault_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["DocumentVersion"]; + }; + }; + default: { + headers: { + [name: string]: unknown; + }; + content: { + /** @example { + * "causes": [], + * "message": "An error has occurred" + * } */ + "application/json": components["schemas"]["SerializedError"]; + }; + }; + }; + }; + put: { + parameters: { + query?: never; + header: { + "device-id": string; + }; + path: { + document_id: string; + vault_id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "multipart/form-data": components["schemas"]["UpdateDocumentVersionMultipart"]; + }; + }; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["DocumentUpdateResponse"]; + }; + }; + default: { + headers: { + [name: string]: unknown; + }; + content: { + /** @example { + * "causes": [], + * "message": "An error has occurred" + * } */ + "application/json": components["schemas"]["SerializedError"]; + }; + }; + }; + }; + post?: never; + delete: { + parameters: { + query?: never; + header: { + "device-id": string; + }; + path: { + document_id: string; + vault_id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["DeleteDocumentVersion"]; + }; + }; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["DocumentVersionWithoutContent"]; + }; + }; + default: { + headers: { + [name: string]: unknown; + }; + content: { + /** @example { + * "causes": [], + * "message": "An error has occurred" + * } */ + "application/json": components["schemas"]["SerializedError"]; + }; + }; + }; + }; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/vaults/{vault_id}/documents/{document_id}/json": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put: { + parameters: { + query?: never; + header: { + "device-id": string; + }; + path: { + document_id: string; + vault_id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["UpdateDocumentVersion"]; + }; + }; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["DocumentUpdateResponse"]; + }; + }; + default: { + headers: { + [name: string]: unknown; + }; + content: { + /** @example { + * "causes": [], + * "message": "An error has occurred" + * } */ + "application/json": components["schemas"]["SerializedError"]; + }; + }; + }; + }; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/vaults/{vault_id}/documents/{document_id}/versions/{version_id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put: { + parameters: { + query?: never; + header?: never; + path: { + document_id: string; + vault_id: string; + vault_update_id: number; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["DocumentVersion"]; + }; + }; + default: { + headers: { + [name: string]: unknown; + }; + content: { + /** @example { + * "causes": [], + * "message": "An error has occurred" + * } */ + "application/json": components["schemas"]["SerializedError"]; + }; + }; + }; + }; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/vaults/{vault_id}/documents/{document_id}/versions/{version_id}/content": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put: { + parameters: { + query?: never; + header?: never; + path: { + document_id: string; + vault_id: string; + vault_update_id: number; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description byte stream */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/octet-stream": unknown; + }; + }; + default: { + headers: { + [name: string]: unknown; + }; + content: { + /** @example { + * "causes": [], + * "message": "An error has occurred" + * } */ + "application/json": components["schemas"]["SerializedError"]; + }; + }; + }; + }; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/vaults/{vault_id}/ping": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: { + parameters: { + query?: never; + header?: { + authorization?: string; + }; + path: { + vault_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["PingResponse"]; + }; + }; + default: { + headers: { + [name: string]: unknown; + }; + content: { + /** @example { + * "causes": [], + * "message": "An error has occurred" + * } */ + "application/json": components["schemas"]["SerializedError"]; + }; + }; + }; + }; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; +} +export type webhooks = Record; +export interface components { + schemas: { + Array_of_uint8: number[]; + CreateDocumentPathParams: { + vault_id: string; + }; + CreateDocumentVersion: { + contentBase64: string; + /** + * Format: uuid + * @description The client can decide the document id (if it wishes to) in order to help with syncing. If the client does not provide a document id, the server will generate one. If the client provides a document id it must not already exist in the database. + */ + documentId?: string | null; + relativePath: string; + }; + CreateDocumentVersionMultipart: { + content: components["schemas"]["Array_of_uint8"]; + /** Format: uuid */ + document_id?: string | null; + relative_path: string; + }; + DeleteDocumentPathParams: { + /** Format: uuid */ + document_id: string; + vault_id: string; + }; + DeleteDocumentVersion: { + relativePath: string; + }; + /** @description Response to an update document request. */ + DocumentUpdateResponse: { + /** Format: uint64 */ + contentSize: number; + deviceId: string; + /** Format: uuid */ + documentId: string; + isDeleted: boolean; + relativePath: string; + /** @enum {string} */ + type: "FastForwardUpdate"; + /** Format: date-time */ + updatedDate: string; + userId: string; + /** Format: int64 */ + vaultUpdateId: number; + } | { + contentBase64: string; + deviceId: string; + /** Format: uuid */ + documentId: string; + isDeleted: boolean; + relativePath: string; + /** @enum {string} */ + type: "MergingUpdate"; + /** Format: date-time */ + updatedDate: string; + userId: string; + /** Format: int64 */ + vaultUpdateId: number; + }; + DocumentVersion: { + contentBase64: string; + deviceId: string; + /** Format: uuid */ + documentId: string; + isDeleted: boolean; + relativePath: string; + /** Format: date-time */ + updatedDate: string; + userId: string; + /** Format: int64 */ + vaultUpdateId: number; + }; + DocumentVersionWithoutContent: { + /** Format: uint64 */ + contentSize: number; + deviceId: string; + /** Format: uuid */ + documentId: string; + isDeleted: boolean; + relativePath: string; + /** Format: date-time */ + updatedDate: string; + userId: string; + /** Format: int64 */ + vaultUpdateId: number; + }; + FetchDocumentVersionContentPathParams: { + /** Format: uuid */ + document_id: string; + vault_id: string; + /** Format: int64 */ + vault_update_id: number; + }; + FetchDocumentVersionPathParams: { + /** Format: uuid */ + document_id: string; + vault_id: string; + /** Format: int64 */ + vault_update_id: number; + }; + FetchLatestDocumentVersionPathParams: { + /** Format: uuid */ + document_id: string; + vault_id: string; + }; + FetchLatestDocumentsPathParams: { + vault_id: string; + }; + /** @description Response to a fetch latest documents request. */ + FetchLatestDocumentsResponse: { + /** + * Format: int64 + * @description The update ID of the latest document in the response. + */ + lastUpdateId: number; + latestDocuments: components["schemas"]["DocumentVersionWithoutContent"][]; + }; + PingPathParams: { + vault_id: string; + }; + /** @description Response to a ping request. */ + PingResponse: { + /** @description Whether the client is authenticated based on the sent Authorization header. */ + isAuthenticated: boolean; + /** @description Semantic version of the server. */ + serverVersion: string; + }; + QueryParams: { + /** Format: int64 */ + since_update_id?: number | null; + }; + SerializedError: { + causes: string[]; + message: string; + }; + UpdateDocumentPathParams: { + /** Format: uuid */ + document_id: string; + vault_id: string; + }; + UpdateDocumentVersion: { + contentBase64: string; + /** Format: int64 */ + parentVersionId: number; + relativePath: string; + }; + UpdateDocumentVersionMultipart: { + content: components["schemas"]["Array_of_uint8"]; + /** Format: int64 */ + parentVersionId: number; + relativePath: string; + }; + WebSocketPathParams: { + vault_id: string; + }; + }; + responses: never; + parameters: never; + requestBodies: never; + headers: never; + pathItems: never; +} +export type $defs = Record; +export type operations = Record; -- 2.47.2 From eeff9f7aa1ff62bdd58e656f56b787b7b88f37a8 Mon Sep 17 00:00:00 2001 From: Andras Schmelczer Date: Sun, 1 Jun 2025 09:59:37 +0100 Subject: [PATCH 05/29] Extract device id generation --- .../sync-client/src/services/sync-service.ts | 26 ++++--------------- .../sync-client/src/utils/create-client-id.ts | 15 +++++++++++ 2 files changed, 20 insertions(+), 21 deletions(-) create mode 100644 frontend/sync-client/src/utils/create-client-id.ts diff --git a/frontend/sync-client/src/services/sync-service.ts b/frontend/sync-client/src/services/sync-service.ts index 741aa012..bbb559ff 100644 --- a/frontend/sync-client/src/services/sync-service.ts +++ b/frontend/sync-client/src/services/sync-service.ts @@ -1,6 +1,6 @@ import type { Client } from "openapi-fetch"; import createClient from "openapi-fetch"; -import type { components, paths } from "./types"; // generated by openapi-typescript +import type { components, paths } from "./types/http-api"; // generated by openapi-typescript import type { DocumentId, RelativePath, @@ -44,19 +44,6 @@ export class SyncService { }); } - private get deviceIdHeader(): string { - // @ts-expect-error, injected by webpack - const packageVersion = __CURRENT_VERSION__; // eslint-disable-line - - const platform = - typeof navigator !== "undefined" - ? navigator.platform // eslint-disable-line @typescript-eslint/no-deprecated - : typeof process !== "undefined" - ? process.platform - : "unknown"; - return `vault-link/${packageVersion} (${this.deviceId}; ${platform})`; - } - private static formatError( error: components["schemas"]["SerializedError"] ): string { @@ -86,7 +73,6 @@ export class SyncService { formData.append("document_id", documentId); } formData.append("relative_path", relativePath); - formData.append("device_id", this.deviceId); formData.append("content", new Blob([contentBytes])); const response = await this.client.POST( @@ -97,7 +83,7 @@ export class SyncService { vault_id: vaultName }, header: { - "device-id": this.deviceIdHeader + "device-id": this.deviceId } }, // eslint-disable-next-line @@ -141,7 +127,6 @@ export class SyncService { const formData = new FormData(); formData.append("parent_version_id", parentVersionId.toString()); formData.append("relative_path", relativePath); - formData.append("device_id", this.deviceId); formData.append("content", new Blob([contentBytes])); const response = await this.client.PUT( @@ -153,7 +138,7 @@ export class SyncService { document_id: documentId }, header: { - "device-id": this.deviceIdHeader + "device-id": this.deviceId } }, // eslint-disable-next-line @@ -196,13 +181,12 @@ export class SyncService { document_id: documentId }, header: { - "device-id": this.deviceIdHeader + "device-id": this.deviceId } }, body: { - relativePath, - deviceId: this.deviceId + relativePath } } ); diff --git a/frontend/sync-client/src/utils/create-client-id.ts b/frontend/sync-client/src/utils/create-client-id.ts new file mode 100644 index 00000000..60143b75 --- /dev/null +++ b/frontend/sync-client/src/utils/create-client-id.ts @@ -0,0 +1,15 @@ +import { v4 as uuidv4 } from "uuid"; + +export function createClientId(): string { + // @ts-expect-error, injected by webpack + const packageVersion = __CURRENT_VERSION__; // eslint-disable-line + + const platform = + typeof navigator !== "undefined" + ? navigator.platform // eslint-disable-line @typescript-eslint/no-deprecated + : typeof process !== "undefined" + ? process.platform + : "unknown"; + + return `vault-link/${packageVersion} (${uuidv4()}; ${platform})`; +} -- 2.47.2 From e7c8d65b23dc803957a751ca733d61d5ffd1c5f1 Mon Sep 17 00:00:00 2001 From: Andras Schmelczer Date: Sun, 1 Jun 2025 10:22:36 +0100 Subject: [PATCH 06/29] Extract WS into own class --- .../sync-client/src/persistence/settings.ts | 4 +- .../src/services/websocket-manager.ts | 196 ++++++++++++++++++ frontend/sync-client/src/sync-client.ts | 30 ++- .../sync-client/src/sync-operations/syncer.ts | 151 +------------- 4 files changed, 224 insertions(+), 157 deletions(-) create mode 100644 frontend/sync-client/src/services/websocket-manager.ts diff --git a/frontend/sync-client/src/persistence/settings.ts b/frontend/sync-client/src/persistence/settings.ts index a62e4f0c..bcb32531 100644 --- a/frontend/sync-client/src/persistence/settings.ts +++ b/frontend/sync-client/src/persistence/settings.ts @@ -8,6 +8,7 @@ export interface SyncSettings { isSyncEnabled: boolean; maxFileSizeMB: number; ignorePatterns: string[]; + webSocketRetryIntervalMs: number; } export const DEFAULT_SETTINGS: SyncSettings = { @@ -17,7 +18,8 @@ export const DEFAULT_SETTINGS: SyncSettings = { syncConcurrency: 1, isSyncEnabled: false, maxFileSizeMB: 10, - ignorePatterns: [] + ignorePatterns: [], + webSocketRetryIntervalMs: 3500 }; export class Settings { diff --git a/frontend/sync-client/src/services/websocket-manager.ts b/frontend/sync-client/src/services/websocket-manager.ts new file mode 100644 index 00000000..8a37f9ff --- /dev/null +++ b/frontend/sync-client/src/services/websocket-manager.ts @@ -0,0 +1,196 @@ +import type { Database } from "../persistence/database"; +import type { Logger } from "../tracing/logger"; +import type { Settings, SyncSettings } from "../persistence/settings"; +import { WebSocketServerMessage } from "./types/WebSocketServerMessage"; +import { Syncer } from "../sync-operations/syncer"; +import { WebSocketClientMessage } from "./types/WebSocketClientMessage"; +import { CursorPositionFromClient } from "./types/CursorPositionFromClient"; + +export class WebSocketManager { + private readonly webSocketStatusChangeListeners: (() => unknown)[] = []; + // private readonly cur: (() => unknown)[] = []; + + private refreshWebSocketInterval: NodeJS.Timeout | undefined; + + private webSocket: WebSocket | undefined; + + private readonly webSocketFactoryImplementation: typeof globalThis.WebSocket; + + // eslint-disable-next-line @typescript-eslint/max-params + public constructor( + private readonly deviceId: string, + private readonly logger: Logger, + private readonly database: Database, + private readonly settings: Settings, + private readonly syncer: Syncer, + webSocketImplementation?: typeof globalThis.WebSocket + ) { + if (webSocketImplementation) { + this.webSocketFactoryImplementation = webSocketImplementation; + } else { + if ( + typeof globalThis !== "undefined" && + typeof globalThis.WebSocket === "undefined" + ) { + // eslint-disable-next-line + this.webSocketFactoryImplementation = require("ws"); // polyfill for WebSocket in Node.js + } else { + this.webSocketFactoryImplementation = WebSocket; + } + } + + this.updateWebSocket(settings.getSettings()); + + settings.addOnSettingsChangeListener((newSettings, oldSettings) => { + if ( + newSettings.remoteUri !== oldSettings.remoteUri || + newSettings.vaultName !== oldSettings.vaultName || + newSettings.token !== oldSettings.token || + newSettings.isSyncEnabled !== oldSettings.isSyncEnabled + ) { + this.updateWebSocket(newSettings); + } + }); + + this.setWebSocketRefreshInterval(); + } + + public get isWebSocketConnected(): boolean { + return ( + this.webSocket?.readyState === + this.webSocketFactoryImplementation.OPEN + ); + } + + public addWebSocketStatusChangeListener(listener: () => void): void { + this.webSocketStatusChangeListeners.push(listener); + } + + public async reset(): Promise { + this.setWebSocketRefreshInterval(); + this.updateWebSocket(this.settings.getSettings()); + } + + public stop(): void { + clearInterval(this.refreshWebSocketInterval); + + try { + this.webSocket?.close(); + } catch (e) { + this.logger.warn(`Failed to close WebSocket: ${e}`); + } + } + + private updateWebSocket(settings: SyncSettings): void { + try { + this.webSocket?.close(); + } catch (e) { + this.logger.warn(`Failed to close WebSocket: ${e}`); + } + + if (!settings.isSyncEnabled) { + this.webSocket = undefined; + return; + } + + const wsUri = new URL(settings.remoteUri); + wsUri.protocol = wsUri.protocol === "https" ? "wss" : "ws"; + wsUri.pathname = `/vaults/${settings.vaultName}/ws`; + + this.logger.info(`Connecting to WebSocket at ${wsUri.toString()}`); + + this.webSocket = new this.webSocketFactoryImplementation(wsUri); + + this.webSocket.onmessage = async (event): Promise => { + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + const message = JSON.parse(event.data) as WebSocketServerMessage; + + if (message.type === "vaultUpdate") { + try { + await Promise.all( + message.documents.map(async (document) => + this.syncer.syncRemotelyUpdatedFile(document) + ) + ); + + if (message.isInitialSync && message.documents.length > 0) { + this.database.setLastSeenUpdateId( + message.documents + .map((document) => document.vaultUpdateId) + .reduce((a, b) => Math.max(a, b)) + ); + } + } catch (e) { + this.logger.error( + `Failed to sync remotely updated file: ${e}` + ); + } + } else if (message.type === "cursorPositions") { + this.logger.info( + `Received cursor positions for ${JSON.stringify(message.clients)}` + ); + // Handle cursor positions if needed + } else { + this.logger.warn( + `Received unknown message type: ${JSON.stringify(message)}` + ); + } + }; + + // The JS WebSocket API doesn't support setting headers, so we have to send the token as a message + this.webSocket.onopen = (): void => { + this.logger.info("WebSocket connection opened"); + this.webSocketStatusChangeListeners.forEach((listener) => { + listener(); + }); + + let message: WebSocketClientMessage = { + type: "handshake", + deviceId: this.deviceId, + token: settings.token, + lastSeenVaultUpdateId: this.database.getLastSeenUpdateId() + }; + this.webSocket?.send(JSON.stringify(message)); + }; + + this.webSocket.onclose = (event): void => { + this.logger.warn( + `WebSocket closed with code ${event.code} (${event.reason == "" ? "unknown reason" : event.reason})` + ); + this.webSocketStatusChangeListeners.forEach((listener) => { + listener(); + }); + }; + } + + public sendCursorPositions( + cursorPositions: CursorPositionFromClient + ): void { + if (!this.isWebSocketConnected) { + this.logger.warn( + "WebSocket is not connected, cannot send cursor positions" + ); + return; + } + let message: WebSocketClientMessage = { + type: "cursorPositions", + ...cursorPositions + }; + this.webSocket?.send(JSON.stringify(message)); + this.logger.info( + `Sent cursor positions: ${JSON.stringify(cursorPositions)}` + ); + } + + private setWebSocketRefreshInterval(): void { + this.refreshWebSocketInterval = setInterval(() => { + if ( + this.webSocket?.readyState === + this.webSocketFactoryImplementation.CLOSED + ) { + this.logger.info("WebSocket is closed, reconnecting..."); + this.updateWebSocket(this.settings.getSettings()); + } + }, this.settings.getSettings().webSocketRetryIntervalMs); + } +} diff --git a/frontend/sync-client/src/sync-client.ts b/frontend/sync-client/src/sync-client.ts index 94c446e8..b9fbbcc2 100644 --- a/frontend/sync-client/src/sync-client.ts +++ b/frontend/sync-client/src/sync-client.ts @@ -15,9 +15,10 @@ import { FileOperations } from "./file-operations/file-operations"; import { ConnectionStatus } from "./services/connection-status"; import { UnrestrictedSyncer } from "./sync-operations/unrestricted-syncer"; import { rateLimit } from "./utils/rate-limit"; -import { v4 as uuidv4 } from "uuid"; import type { NetworkConnectionStatus } from "./types/network-connection-status"; import { DocumentUpdateStatus } from "./types/document-update-status"; +import { WebSocketManager } from "./services/websocket-manager"; +import { createClientId } from "./utils/create-client-id"; export class SyncClient { private static readonly MINIMUM_SAVE_INTERVAL_MS = 1000; @@ -29,6 +30,7 @@ export class SyncClient { private readonly database: Database, private readonly syncer: Syncer, private readonly syncService: SyncService, + private readonly webSocketManager: WebSocketManager, private readonly _logger: Logger, private readonly connectionStatus: ConnectionStatus ) { @@ -68,7 +70,10 @@ export class SyncClient { nativeLineEndings?: string; }): Promise { const logger = new Logger(); - logger.info("Initialising SyncClient"); + + const deviceId = createClientId(); + + logger.info(`Initialising SyncClient with client id ${deviceId}`); const history = new SyncHistory(logger); @@ -104,7 +109,6 @@ export class SyncClient { await rateLimitedSave(state); } ); - const deviceId = uuidv4(); const connectionStatus = new ConnectionStatus(settings, logger); const syncService = new SyncService( @@ -121,6 +125,7 @@ export class SyncClient { fs, nativeLineEndings ); + const unrestrictedSyncer = new UnrestrictedSyncer( logger, database, @@ -129,6 +134,7 @@ export class SyncClient { fileOperations, history ); + const syncer = new Syncer( deviceId, logger, @@ -136,7 +142,15 @@ export class SyncClient { settings, syncService, fileOperations, - unrestrictedSyncer, + unrestrictedSyncer + ); + + const webSocketManager = new WebSocketManager( + deviceId, + logger, + database, + settings, + syncer, webSocket ); @@ -146,6 +160,7 @@ export class SyncClient { database, syncer, syncService, + webSocketManager, logger, connectionStatus ); @@ -160,7 +175,7 @@ export class SyncClient { return { isSuccessful: server.isSuccessful, serverMessage: server.message, - isWebSocketConnected: this.syncer.isWebSocketConnected + isWebSocketConnected: this.webSocketManager.isWebSocketConnected }; } @@ -179,7 +194,7 @@ export class SyncClient { } public stop(): void { - this.syncer.stop(); + this.webSocketManager.stop(); } public async waitAndStop(): Promise { @@ -194,6 +209,7 @@ export class SyncClient { this.stop(); this.connectionStatus.startReset(); await this.syncer.reset(); + await this.webSocketManager.reset(); this.history.reset(); this.database.reset(); this._logger.reset(); @@ -229,7 +245,7 @@ export class SyncClient { } public addWebSocketStatusChangeListener(listener: () => void): void { - this.syncer.addWebSocketStatusChangeListener(listener); + this.webSocketManager.addWebSocketStatusChangeListener(listener); } public async syncLocallyCreatedFile( diff --git a/frontend/sync-client/src/sync-operations/syncer.ts b/frontend/sync-client/src/sync-operations/syncer.ts index e141ce9d..1498fbfa 100644 --- a/frontend/sync-client/src/sync-operations/syncer.ts +++ b/frontend/sync-client/src/sync-operations/syncer.ts @@ -18,26 +18,14 @@ import { createPromise } from "../utils/create-promise"; import { SyncResetError } from "../services/sync-reset-error"; import { Locks } from "../utils/locks"; -interface WebsocketVaultUpdate { - documents: components["schemas"]["DocumentVersionWithoutContent"][]; - isInitialSync: boolean; -} - export class Syncer { private readonly remoteDocumentsLock: Locks; private readonly remainingOperationsListeners: (( remainingOperations: number ) => void)[] = []; - private readonly webSocketStatusChangeListeners: (() => void)[] = []; private readonly syncQueue: PQueue; private runningScheduleSyncForOfflineChanges: Promise | undefined; - private refreshApplyRemoteChangesWebSocketInterval: - | NodeJS.Timeout - | undefined; - private applyRemoteChangesWebSocket: WebSocket | undefined; - - private readonly webSocketImplementation: typeof globalThis.WebSocket; // eslint-disable-next-line @typescript-eslint/max-params public constructor( @@ -47,41 +35,15 @@ export class Syncer { private readonly settings: Settings, private readonly syncService: SyncService, private readonly operations: FileOperations, - private readonly internalSyncer: UnrestrictedSyncer, - webSocketImplementation?: typeof globalThis.WebSocket + private readonly internalSyncer: UnrestrictedSyncer ) { this.syncQueue = new PQueue({ concurrency: settings.getSettings().syncConcurrency }); - if (webSocketImplementation) { - this.webSocketImplementation = webSocketImplementation; - } else { - if ( - typeof globalThis !== "undefined" && - typeof globalThis.WebSocket === "undefined" - ) { - // eslint-disable-next-line - this.webSocketImplementation = require("ws"); // polyfill for WebSocket in Node.js - } else { - this.webSocketImplementation = WebSocket; - } - } - - this.updateWebSocket(settings.getSettings()); - this.remoteDocumentsLock = new Locks(this.logger); settings.addOnSettingsChangeListener((newSettings, oldSettings) => { - if ( - newSettings.remoteUri !== oldSettings.remoteUri || - newSettings.vaultName !== oldSettings.vaultName || - newSettings.token !== oldSettings.token || - newSettings.isSyncEnabled !== oldSettings.isSyncEnabled - ) { - this.updateWebSocket(newSettings); - } - if (newSettings.syncConcurrency !== oldSettings.syncConcurrency) { this.syncQueue.concurrency = newSettings.syncConcurrency; } @@ -92,15 +54,6 @@ export class Syncer { listener(this.syncQueue.size); }); }); - - this.setWebSocketRefreshInterval(); - } - - public get isWebSocketConnected(): boolean { - return ( - this.applyRemoteChangesWebSocket?.readyState === - this.webSocketImplementation.OPEN - ); } public addRemainingOperationsListener( @@ -109,10 +62,6 @@ export class Syncer { this.remainingOperationsListeners.push(listener); } - public addWebSocketStatusChangeListener(listener: () => void): void { - this.webSocketStatusChangeListeners.push(listener); - } - public async syncLocallyCreatedFile( relativePath: RelativePath ): Promise { @@ -303,105 +252,9 @@ export class Syncer { public async reset(): Promise { await this.waitUntilFinished(); - this.setWebSocketRefreshInterval(); - this.updateWebSocket(this.settings.getSettings()); } - public stop(): void { - clearInterval(this.refreshApplyRemoteChangesWebSocketInterval); - - try { - this.applyRemoteChangesWebSocket?.close(); - } catch (e) { - this.logger.warn(`Failed to close WebSocket: ${e}`); - } - } - - private updateWebSocket(settings: SyncSettings): void { - try { - this.applyRemoteChangesWebSocket?.close(); - } catch (e) { - this.logger.warn(`Failed to close WebSocket: ${e}`); - } - - if (!settings.isSyncEnabled) { - this.applyRemoteChangesWebSocket = undefined; - return; - } - - const wsUri = new URL(settings.remoteUri); - wsUri.protocol = wsUri.protocol === "https" ? "wss" : "ws"; - wsUri.pathname = `/vaults/${settings.vaultName}/ws`; - - this.logger.info(`Connecting to WebSocket at ${wsUri.toString()}`); - - this.applyRemoteChangesWebSocket = new this.webSocketImplementation( - wsUri - ); - - this.applyRemoteChangesWebSocket.onmessage = async ( - event - ): Promise => { - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion - const message = JSON.parse(event.data) as WebsocketVaultUpdate; - - try { - await Promise.all( - message.documents.map(async (document) => - this.syncRemotelyUpdatedFile(document) - ) - ); - - if (message.isInitialSync && message.documents.length > 0) { - this.database.setLastSeenUpdateId( - message.documents - .map((document) => document.vaultUpdateId) - .reduce((a, b) => Math.max(a, b)) - ); - } - } catch (e) { - this.logger.error(`Failed to sync remotely updated file: ${e}`); - } - }; - - // The JS WebSocket API doesn't support setting headers, so we have to send the token as a message - this.applyRemoteChangesWebSocket.onopen = (): void => { - this.logger.info("WebSocket connection opened"); - this.applyRemoteChangesWebSocket?.send( - JSON.stringify({ - deviceId: this.deviceId, - token: settings.token, - lastSeenVaultUpdateId: this.database.getLastSeenUpdateId() - }) - ); - this.webSocketStatusChangeListeners.forEach((listener) => { - listener(); - }); - }; - - this.applyRemoteChangesWebSocket.onclose = (event): void => { - this.logger.warn( - `WebSocket closed with code ${event.code} (${event.reason == "" ? "unknown reason" : event.reason})` - ); - this.webSocketStatusChangeListeners.forEach((listener) => { - listener(); - }); - }; - } - - private setWebSocketRefreshInterval(): void { - this.refreshApplyRemoteChangesWebSocketInterval = setInterval(() => { - if ( - this.applyRemoteChangesWebSocket?.readyState === - this.webSocketImplementation.OPEN - ) { - return; - } - this.updateWebSocket(this.settings.getSettings()); - }, 5000); - } - - private async syncRemotelyUpdatedFile( + public async syncRemotelyUpdatedFile( remoteVersion: components["schemas"]["DocumentVersionWithoutContent"] ): Promise { let document = this.database.getDocumentByDocumentId( -- 2.47.2 From 5ce6143838fc953f386da1d97c01286f84555e85 Mon Sep 17 00:00:00 2001 From: Andras Schmelczer Date: Sun, 1 Jun 2025 20:29:20 +0100 Subject: [PATCH 07/29] Add dummy cursor --- .../obsidian-plugin/src/vault-link-plugin.ts | 12 +- .../remote-cursors/remote-cursor-theme.ts | 67 +++++++++++ .../remote-cursors/remote-cursor-widget.ts | 47 ++++++++ .../remote-cursors/remote-cursors-plugin.ts | 110 ++++++++++++++++++ 4 files changed, 234 insertions(+), 2 deletions(-) create mode 100644 frontend/obsidian-plugin/src/views/remote-cursors/remote-cursor-theme.ts create mode 100644 frontend/obsidian-plugin/src/views/remote-cursors/remote-cursor-widget.ts create mode 100644 frontend/obsidian-plugin/src/views/remote-cursors/remote-cursors-plugin.ts diff --git a/frontend/obsidian-plugin/src/vault-link-plugin.ts b/frontend/obsidian-plugin/src/vault-link-plugin.ts index 1e79be35..efd54417 100644 --- a/frontend/obsidian-plugin/src/vault-link-plugin.ts +++ b/frontend/obsidian-plugin/src/vault-link-plugin.ts @@ -16,7 +16,10 @@ import { ObsidianFileSystemOperations } from "./obsidian-file-system"; import { SyncSettingsTab } from "./views/settings/settings-tab"; import { registerConsoleForLogging } from "./utils/register-console-for-logging"; import { updateEditorStatusDisplay } from "./views/editor-sync-line/editor-sync-line"; +import { remoteCursorsTheme } from "./views/remote-cursors/remote-cursor-theme"; +import { remoteCursorsPlugin } from "./views/remote-cursors/remote-cursors-plugin"; +const MIN_WAIT_BETWEEN_UPDATES_IN_MS = 250; export default class VaultLinkPlugin extends Plugin { private readonly disposables: (() => unknown)[] = []; private settingsTab: SyncSettingsTab | undefined; @@ -61,18 +64,23 @@ export default class VaultLinkPlugin extends Plugin { this.registerView( HistoryView.TYPE, - (leaf) => new HistoryView(leaf, this.client) + (leaf) => new HistoryView(this.client, leaf) ); + this.registerView( LogsView.TYPE, (leaf) => new LogsView(this.client, leaf) ); + this.registerEditorExtension([remoteCursorsTheme, remoteCursorsPlugin]); + this.app.workspace.updateOptions(); + this.addRibbonIcon( HistoryView.ICON, "Open VaultLink events", async (_: MouseEvent) => this.activateView(HistoryView.TYPE) ); + this.addRibbonIcon( LogsView.ICON, "Open VaultLink logs", @@ -181,7 +189,7 @@ export default class VaultLinkPlugin extends Plugin { this.client.syncLocallyUpdatedFile({ relativePath: path }), - 250 + MIN_WAIT_BETWEEN_UPDATES_IN_MS ) ); } diff --git a/frontend/obsidian-plugin/src/views/remote-cursors/remote-cursor-theme.ts b/frontend/obsidian-plugin/src/views/remote-cursors/remote-cursor-theme.ts new file mode 100644 index 00000000..373ec9f1 --- /dev/null +++ b/frontend/obsidian-plugin/src/views/remote-cursors/remote-cursor-theme.ts @@ -0,0 +1,67 @@ +import { AnnotationType, Annotation, RangeSet, Range } from "@codemirror/state"; +import { + EditorView, + ViewUpdate, + ViewPlugin, + Decoration, + WidgetType +} from "@codemirror/view"; + +import type { PluginValue, DecorationSet } from "@codemirror/view"; + +export const remoteCursorsTheme = EditorView.baseTheme({ + ".Selection": {}, + ".LineSelection": { + padding: 0, + margin: "0px 2px 0px 4px" + }, + ".SelectionCaret": { + position: "relative", + borderLeft: "1px solid black", + borderRight: "1px solid black", + marginLeft: "-1px", + marginRight: "-1px", + boxSizing: "border-box", + display: "inline" + }, + ".SelectionCaretDot": { + borderRadius: "50%", + position: "absolute", + width: ".4em", + height: ".4em", + top: "-.2em", + left: "-.2em", + backgroundColor: "inherit", + transition: "transform .3s ease-in-out", + boxSizing: "border-box" + }, + ".SelectionCaret:hover > .SelectionCaretDot": { + transformOrigin: "bottom center", + transform: "scale(0)" + }, + ".SelectionInfo": { + position: "absolute", + top: "-1.05em", + left: "-1px", + fontSize: ".75em", + fontFamily: "serif", + fontStyle: "normal", + fontWeight: "normal", + lineHeight: "normal", + userSelect: "none", + color: "white", + paddingLeft: "2px", + paddingRight: "2px", + zIndex: 101, + transition: "opacity .3s ease-in-out", + backgroundColor: "inherit", + // these should be separate + opacity: 0, + transitionDelay: "0s", + whiteSpace: "nowrap" + }, + ".SelectionCaret:hover > .SelectionInfo": { + opacity: 1, + transitionDelay: "0s" + } +}); diff --git a/frontend/obsidian-plugin/src/views/remote-cursors/remote-cursor-widget.ts b/frontend/obsidian-plugin/src/views/remote-cursors/remote-cursor-widget.ts new file mode 100644 index 00000000..767311ea --- /dev/null +++ b/frontend/obsidian-plugin/src/views/remote-cursors/remote-cursor-widget.ts @@ -0,0 +1,47 @@ +import { AnnotationType, Annotation, RangeSet, Range } from "@codemirror/state"; +import { + EditorView, + ViewUpdate, + ViewPlugin, + Decoration, + WidgetType +} from "@codemirror/view"; + +import type { PluginValue, DecorationSet } from "@codemirror/view"; + +export class RemoteCursorWidget extends WidgetType { + public constructor( + private readonly color: string, + private readonly name: string + ) { + super(); + } + + public toDOM(editor: EditorView): HTMLElement { + return editor.contentDOM.createEl( + "span", + { + cls: "SelectionCaret", + attr: { + style: `background-color: ${this.color}; border-color: ${this.color}` + } + }, + (span) => { + span.appendText("\u2060"); + span.createEl("div", { + cls: "SelectionCaretDot" + }); + span.appendText("\u2060"); + span.createEl("div", { + cls: "SelectionInfo", + text: this.name + }); + span.appendText("\u2060"); + } + ); + } + + public eq(other: RemoteCursorWidget) { + return other.color === this.color && other.name === this.name; + } +} diff --git a/frontend/obsidian-plugin/src/views/remote-cursors/remote-cursors-plugin.ts b/frontend/obsidian-plugin/src/views/remote-cursors/remote-cursors-plugin.ts new file mode 100644 index 00000000..52e2d5e0 --- /dev/null +++ b/frontend/obsidian-plugin/src/views/remote-cursors/remote-cursors-plugin.ts @@ -0,0 +1,110 @@ +import { RangeSet, Range } from "@codemirror/state"; +import { + EditorView, + ViewUpdate, + ViewPlugin, + Decoration, + WidgetType +} from "@codemirror/view"; + +import type { PluginValue, DecorationSet } from "@codemirror/view"; +import { RemoteCursorWidget } from "./remote-cursor-widget"; + +export class RemoteCursorsPluginValue implements PluginValue { + public decorations: DecorationSet = RangeSet.of([]); + + public constructor(private readonly _editor: EditorView) {} + + public update(update: ViewUpdate) { + const decorations: Array> = []; + + const cursors: { + name: string; + color: string; + anchor: { index: number }; + head: { index: number }; + }[] = [ + { + name: "Alice", + color: "#ff6b6b", + anchor: { index: 10 }, + head: { index: 20 } + } + ]; + + cursors.forEach(({ name, color, anchor, head }) => { + const start = Math.min(anchor.index, head.index); + const end = Math.max(anchor.index, head.index); + const startLine = update.view.state.doc.lineAt(start); + const endLine = update.view.state.doc.lineAt(end); + + if (startLine.number === endLine.number) { + // selected content in a single line. + decorations.push({ + from: start, + to: end, + value: Decoration.mark({ + attributes: { + style: `background-color: ${color}` + }, + class: "Selection" + }) + }); + } else { + // selected content in multiple lines + // first, render text-selection in the first line + decorations.push({ + from: start, + to: startLine.from + startLine.length, + value: Decoration.mark({ + attributes: { + style: `background-color: ${color}` + }, + class: "Selection" + }) + }); + // render text-selection in the last line + decorations.push({ + from: endLine.from, + to: end, + value: Decoration.mark({ + attributes: { + style: `background-color: ${color}` + }, + class: "Selection" + }) + }); + for (let i = startLine.number + 1; i < endLine.number; i++) { + const linePos = update.view.state.doc.line(i).from; + decorations.push({ + from: linePos, + to: linePos, + value: Decoration.line({ + attributes: { + style: `background-color: ${color}`, + class: "LineSelection" + } + }) + }); + } + } + decorations.push({ + from: head.index, + to: head.index, + value: Decoration.widget({ + side: head.index - anchor.index > 0 ? -1 : 1, // the local cursor should be rendered outside the remote selection + block: false, + widget: new RemoteCursorWidget(color, name) + }) + }); + }); + this.decorations = Decoration.set(decorations, true); + } +} + +export const remoteCursorsPlugin = ViewPlugin.fromClass( + RemoteCursorsPluginValue, + { + decorations: (v) => v.decorations + } +); -- 2.47.2 From e37399dc2964373125a9196158f7f376bb27fcef Mon Sep 17 00:00:00 2001 From: Andras Schmelczer Date: Sun, 1 Jun 2025 20:29:48 +0100 Subject: [PATCH 08/29] Fix TS compile --- .../obsidian-plugin/src/views/history/history-view.ts | 4 ++-- frontend/obsidian-plugin/webpack.config.js | 11 ++++++++++- frontend/sync-client/src/sync-operations/syncer.ts | 2 +- .../src/sync-operations/unrestricted-syncer.ts | 2 +- 4 files changed, 14 insertions(+), 5 deletions(-) diff --git a/frontend/obsidian-plugin/src/views/history/history-view.ts b/frontend/obsidian-plugin/src/views/history/history-view.ts index 977138a2..68681f3e 100644 --- a/frontend/obsidian-plugin/src/views/history/history-view.ts +++ b/frontend/obsidian-plugin/src/views/history/history-view.ts @@ -18,8 +18,8 @@ export class HistoryView extends ItemView { >(); public constructor( - leaf: WorkspaceLeaf, - private readonly client: SyncClient + private readonly client: SyncClient, + leaf: WorkspaceLeaf ) { super(leaf); this.icon = HistoryView.ICON; diff --git a/frontend/obsidian-plugin/webpack.config.js b/frontend/obsidian-plugin/webpack.config.js index 2b5a803d..8a193c3e 100644 --- a/frontend/obsidian-plugin/webpack.config.js +++ b/frontend/obsidian-plugin/webpack.config.js @@ -12,7 +12,16 @@ module.exports = (env, argv) => ({ ignored: "**/node_modules" }, externals: { - obsidian: "commonjs obsidian" + obsidian: "commonjs obsidian", + electron: "commonjs electron", + "@codemirror/autocomplete": "commonjs @codemirror/autocomplete", + "@codemirror/collab": "commonjs @codemirror/collab", + "@codemirror/commands": "commonjs @codemirror/commands", + "@codemirror/language": "commonjs @codemirror/language", + "@codemirror/lint": "commonjs @codemirror/lint", + "@codemirror/search": "commonjs @codemirror/search", + "@codemirror/state": "commonjs @codemirror/state", + "@codemirror/view": "commonjs @codemirror/view" }, optimization: { minimizer: [ diff --git a/frontend/sync-client/src/sync-operations/syncer.ts b/frontend/sync-client/src/sync-operations/syncer.ts index 1498fbfa..57d99f14 100644 --- a/frontend/sync-client/src/sync-operations/syncer.ts +++ b/frontend/sync-client/src/sync-operations/syncer.ts @@ -9,7 +9,7 @@ import type { Logger } from "../tracing/logger"; import PQueue from "p-queue"; import { hash } from "../utils/hash"; import { v4 as uuidv4 } from "uuid"; -import type { components } from "../services/types"; +import type { components } from "../services/types/http-api"; import type { Settings, SyncSettings } from "../persistence/settings"; import type { FileOperations } from "../file-operations/file-operations"; import { findMatchingFile } from "../utils/find-matching-file"; diff --git a/frontend/sync-client/src/sync-operations/unrestricted-syncer.ts b/frontend/sync-client/src/sync-operations/unrestricted-syncer.ts index b9780939..76470120 100644 --- a/frontend/sync-client/src/sync-operations/unrestricted-syncer.ts +++ b/frontend/sync-client/src/sync-operations/unrestricted-syncer.ts @@ -17,7 +17,7 @@ import type { } from "../tracing/sync-history"; import { SyncStatus, SyncType } from "../tracing/sync-history"; import { EMPTY_HASH, hash } from "../utils/hash"; -import type { components } from "../services/types"; +import type { components } from "../services/types/http-api"; import { deserialize } from "../utils/deserialize"; import type { Settings } from "../persistence/settings"; import type { FileOperations } from "../file-operations/file-operations"; -- 2.47.2 From b60cb0104ba476edd363bb9a94b1a2cc1e712b83 Mon Sep 17 00:00:00 2001 From: Andras Schmelczer Date: Sat, 7 Jun 2025 11:31:14 +0100 Subject: [PATCH 09/29] Make cursor broadcast configurable --- backend/Cargo.lock | 66 ++++++++++++++++--- backend/config-e2e.yml | 34 +++++----- backend/sync_server/Cargo.toml | 1 + backend/sync_server/src/app_state/cursors.rs | 16 ++--- .../sync_server/src/config/database_config.rs | 30 +++++++-- backend/sync_server/src/consts.rs | 5 +- 6 files changed, 107 insertions(+), 45 deletions(-) diff --git a/backend/Cargo.lock b/backend/Cargo.lock index 2f009e1d..cebf93bf 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -51,7 +51,7 @@ dependencies = [ "bytes", "cfg-if", "http", - "indexmap", + "indexmap 2.7.0", "schemars", "serde", "serde_json", @@ -71,7 +71,7 @@ dependencies = [ "aide", "axum", "axum_typed_multipart", - "indexmap", + "indexmap 2.7.0", "schemars", ] @@ -662,6 +662,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b42b6fa04a440b495c8b04d0e71b707c585f83cb9cb28cf8cd0d976c315e31b4" dependencies = [ "powerfmt", + "serde", ] [[package]] @@ -966,6 +967,12 @@ version = "0.31.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "07e28edb80900c19c28f1072f2e8aeca7fa06b23cd4169cefe1af5aa3260783f" +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" + [[package]] name = "hashbrown" version = "0.15.2" @@ -983,7 +990,7 @@ version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1" dependencies = [ - "hashbrown", + "hashbrown 0.15.2", ] [[package]] @@ -1298,6 +1305,17 @@ dependencies = [ "icu_properties", ] +[[package]] +name = "indexmap" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" +dependencies = [ + "autocfg", + "hashbrown 0.12.3", + "serde", +] + [[package]] name = "indexmap" version = "2.7.0" @@ -1305,7 +1323,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "62f822373a4fe84d4bb149bf54e584a7f4abec90e072ed49cda0edea5b95471f" dependencies = [ "equivalent", - "hashbrown", + "hashbrown 0.15.2", "serde", ] @@ -2068,7 +2086,7 @@ dependencies = [ "bytes", "chrono", "dyn-clone", - "indexmap", + "indexmap 2.7.0", "schemars_derive", "serde", "serde_json", @@ -2177,13 +2195,43 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_with" +version = "3.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6b6f7f2fcb69f747921f79f3926bd1e203fce4fef62c268dd3abfb6d86029aa" +dependencies = [ + "base64 0.22.1", + "chrono", + "hex", + "indexmap 1.9.3", + "indexmap 2.7.0", + "serde", + "serde_derive", + "serde_json", + "serde_with_macros", + "time", +] + +[[package]] +name = "serde_with_macros" +version = "3.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d00caa5193a3c8362ac2b73be6b9e768aa5a4b2f721d8f4b339600c3cb51f8e" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn 2.0.90", +] + [[package]] name = "serde_yaml" version = "0.9.34+deprecated" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" dependencies = [ - "indexmap", + "indexmap 2.7.0", "itoa", "ryu", "serde", @@ -2329,9 +2377,9 @@ dependencies = [ "futures-intrusive", "futures-io", "futures-util", - "hashbrown", + "hashbrown 0.15.2", "hashlink", - "indexmap", + "indexmap 2.7.0", "log", "memchr", "once_cell", @@ -2581,6 +2629,7 @@ dependencies = [ "schemars", "serde", "serde_json", + "serde_with", "serde_yaml", "sqlx", "sync_lib", @@ -2722,6 +2771,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "35e7868883861bd0e56d9ac6efcaaca0d6d5d82a2a7ec8209ff492c07cf37b21" dependencies = [ "deranged", + "itoa", "num-conv", "powerfmt", "serde", diff --git a/backend/config-e2e.yml b/backend/config-e2e.yml index 17b745ea..5018c716 100644 --- a/backend/config-e2e.yml +++ b/backend/config-e2e.yml @@ -1,29 +1,27 @@ database: databases_directory_path: databases max_connections_per_vault: 12 - + cursor_timeout_seconds: 60 + cursor_broadcast_interval_seconds: 1 server: host: 0.0.0.0 port: 3000 max_body_size_mb: 512 max_clients_per_vault: 256 response_timeout_seconds: 60 - users: user_configs: - - name: admin - token: test-token-change-me - vault_access: - type: allow_access_to_all - - - name: other-admin - token: test-token-change-me2 - vault_access: - type: allow_access_to_all - - - name: test - token: other-test-token - vault_access: - type: allow_list - allowed: - - default + - name: admin + token: test-token-change-me + vault_access: + type: allow_access_to_all + - name: other-admin + token: test-token-change-me2 + vault_access: + type: allow_access_to_all + - name: test + token: other-test-token + vault_access: + type: allow_list + allowed: + - default diff --git a/backend/sync_server/Cargo.toml b/backend/sync_server/Cargo.toml index e593dc3b..4aa1a373 100644 --- a/backend/sync_server/Cargo.toml +++ b/backend/sync_server/Cargo.toml @@ -38,6 +38,7 @@ serde_json = "1.0.140" clap-verbosity-flag = "3.0.3" bimap = "0.6.3" ts-rs = { version = "10.1", features = ["uuid-impl", "chrono-impl"] } +serde_with = "3.12.0" [lints] workspace = true diff --git a/backend/sync_server/src/app_state/cursors.rs b/backend/sync_server/src/app_state/cursors.rs index 851566a7..d5aa01e4 100644 --- a/backend/sync_server/src/app_state/cursors.rs +++ b/backend/sync_server/src/app_state/cursors.rs @@ -1,8 +1,6 @@ use core::time::Duration; use std::{collections::HashMap, sync::Arc}; -use chrono::TimeDelta; -use sqlx::types::chrono::Utc; use tokio::sync::Mutex; use super::{ @@ -10,15 +8,13 @@ use super::{ websocket::{ broadcasts::Broadcasts, models::{ - ClientCursors, CursorPositionFromServer, WebSocketServerMessage, + ClientCursors, CursorPositionFromServer, CursorSpan, WebSocketServerMessage, WebSocketServerMessageWithOrigin, }, }, }; use crate::config::database_config::DatabaseConfig; -const BACKGROUND_TASK_INTERVAL: Duration = Duration::from_secs(1); - #[derive(Clone, Debug)] pub struct Cursors { config: DatabaseConfig, @@ -39,7 +35,7 @@ impl Cursors { &self, vault_id: VaultId, device_id: &DeviceId, - document_to_cursors: HashMap>, + document_to_cursors: HashMap>, ) { let mut vault_to_cursors = self.vault_to_cursors.lock().await; @@ -76,7 +72,7 @@ impl Cursors { loop { self.remove_expired_cursors().await; self.broadcast_cursors().await; - tokio::time::sleep(BACKGROUND_TASK_INTERVAL).await; + tokio::time::sleep(self.config.cursor_broadcast_interval).await; } } @@ -109,16 +105,16 @@ impl Cursors { #[derive(Clone, Debug)] struct ClientCursorsWithTimeToLive { client_cursors: ClientCursors, - last_updated: chrono::DateTime, + last_updated: std::time::Instant, } impl ClientCursorsWithTimeToLive { fn new(client_cursors: ClientCursors) -> Self { Self { client_cursors, - last_updated: Utc::now(), + last_updated: std::time::Instant::now(), } } - pub fn is_expired(&self, ttl: TimeDelta) -> bool { Utc::now() - self.last_updated > ttl } + pub fn is_expired(&self, ttl: Duration) -> bool { self.last_updated.elapsed() > ttl } } diff --git a/backend/sync_server/src/config/database_config.rs b/backend/sync_server/src/config/database_config.rs index 6f91e19c..118d805e 100644 --- a/backend/sync_server/src/config/database_config.rs +++ b/backend/sync_server/src/config/database_config.rs @@ -1,13 +1,15 @@ -use std::path::PathBuf; +use std::{path::PathBuf, time::Duration}; -use chrono::TimeDelta; use log::debug; use serde::{Deserialize, Serialize}; +use serde_with::serde_as; use crate::consts::{ - DEFAULT_CURSOR_TIMEOUT, DEFAULT_DATABASES_DIRECTORY_PATH, DEFAULT_MAX_CONNECTIONS_PER_VAULT, + DEFAULT_CURSOR_BROADCAST_INTERVAL, DEFAULT_CURSOR_TIMEOUT, DEFAULT_DATABASES_DIRECTORY_PATH, + DEFAULT_MAX_CONNECTIONS_PER_VAULT, }; +#[serde_with::serde_as] #[derive(Debug, Deserialize, Serialize, Clone)] pub struct DatabaseConfig { #[serde(default = "default_databases_directory_path")] @@ -16,8 +18,16 @@ pub struct DatabaseConfig { #[serde(default = "default_max_connections_per_vault")] pub max_connections_per_vault: u32, - #[serde(default = "default_cursor_timeout")] - pub cursor_timeout: TimeDelta, + #[serde(default = "default_cursor_timeout", rename = "cursor_timeout_seconds")] + #[serde_as(as = "serde_with::DurationSeconds")] + pub cursor_timeout: Duration, + + #[serde( + default = "default_cursor_broadcast_interval", + rename = "cursor_broadcast_interval_seconds" + )] + #[serde_as(as = "serde_with::DurationSeconds")] + pub cursor_broadcast_interval: Duration, } fn default_databases_directory_path() -> PathBuf { @@ -30,17 +40,23 @@ fn default_max_connections_per_vault() -> u32 { DEFAULT_MAX_CONNECTIONS_PER_VAULT } -fn default_cursor_timeout() -> TimeDelta { - debug!("Using default cursor timeout: {DEFAULT_CURSOR_TIMEOUT}"); +fn default_cursor_timeout() -> Duration { + debug!("Using default cursor timeout: {DEFAULT_CURSOR_TIMEOUT:?}"); DEFAULT_CURSOR_TIMEOUT } +fn default_cursor_broadcast_interval() -> Duration { + debug!("Using default cursor broadcast interval: {DEFAULT_CURSOR_BROADCAST_INTERVAL:?}"); + DEFAULT_CURSOR_BROADCAST_INTERVAL +} + impl Default for DatabaseConfig { fn default() -> Self { Self { databases_directory_path: default_databases_directory_path(), max_connections_per_vault: default_max_connections_per_vault(), cursor_timeout: default_cursor_timeout(), + cursor_broadcast_interval: default_cursor_broadcast_interval(), } } } diff --git a/backend/sync_server/src/consts.rs b/backend/sync_server/src/consts.rs index 03d5f4c2..01927335 100644 --- a/backend/sync_server/src/consts.rs +++ b/backend/sync_server/src/consts.rs @@ -1,10 +1,11 @@ -use chrono::TimeDelta; +use std::time::Duration; pub const DEFAULT_CONFIG_PATH: &str = "config.yml"; pub const DEFAULT_DATABASES_DIRECTORY_PATH: &str = "databases"; pub const DEFAULT_MAX_CONNECTIONS_PER_VAULT: u32 = 12; -pub const DEFAULT_CURSOR_TIMEOUT: TimeDelta = TimeDelta::seconds(60); +pub const DEFAULT_CURSOR_TIMEOUT: Duration = Duration::from_secs(60); +pub const DEFAULT_CURSOR_BROADCAST_INTERVAL: Duration = Duration::from_secs(1); pub const DEFAULT_HOST: &str = "127.0.0.1"; pub const DEFAULT_PORT: u16 = 3000; -- 2.47.2 From ffb1637183bf7b867ba241d0e26af2cf791da7a7 Mon Sep 17 00:00:00 2001 From: Andras Schmelczer Date: Sat, 7 Jun 2025 11:31:28 +0100 Subject: [PATCH 10/29] Merge log lines --- backend/sync_server/src/server/auth.rs | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/backend/sync_server/src/server/auth.rs b/backend/sync_server/src/server/auth.rs index 6727501e..d27c16e3 100644 --- a/backend/sync_server/src/server/auth.rs +++ b/backend/sync_server/src/server/auth.rs @@ -47,19 +47,22 @@ pub fn auth(state: &AppState, token: &str, vault_id: &VaultId) -> Result true, VaultAccess::AllowList(AllowListedVaults { ref allowed }) => allowed.contains(vault_id), } { info!( - "User `{}` is authorised to access to vault `{}`", - user.name, vault_id + "User '{}' is authenticated and is authorised to access to vault '{vault_id}'", + user.name ); Ok(user) } else { + info!( + "User '{}' is authenticated but is not authorised to access vault '{vault_id}'", + user.name + ); + Err(permission_denied_error(anyhow::anyhow!( "Permission denied for vault `{vault_id}`" ))) -- 2.47.2 From 1475a549d421fccb75996f73fe8478ccc5d734f0 Mon Sep 17 00:00:00 2001 From: Andras Schmelczer Date: Sat, 7 Jun 2025 11:31:41 +0100 Subject: [PATCH 11/29] Always write config --- backend/sync_server/src/config.rs | 24 +++++++++++++----------- 1 file changed, 13 insertions(+), 11 deletions(-) diff --git a/backend/sync_server/src/config.rs b/backend/sync_server/src/config.rs index 8e4dcef3..700b1ea8 100644 --- a/backend/sync_server/src/config.rs +++ b/backend/sync_server/src/config.rs @@ -2,7 +2,7 @@ use std::path::Path; use anyhow::{Context as _, Result}; use database_config::DatabaseConfig; -use log::{info, warn}; +use log::info; use serde::{Deserialize, Serialize}; use server_config::ServerConfig; use tokio::fs; @@ -24,21 +24,23 @@ pub struct Config { impl Config { pub async fn read_or_create(path: &Path) -> Result { - if path.exists() { + let config = if path.exists() { info!( "Loading configuration from '{}'", path.canonicalize().unwrap().display() ); - Self::load_from_file(path).await + Self::load_from_file(path).await? } else { - let config = Self::default(); - config.write(path).await?; - warn!( - "Configuration file not found, wrote default configuration to '{}'", - path.canonicalize().unwrap().display() - ); - Ok(config) - } + Self::default() + }; + + config.write(path).await?; + info!( + "Updated configuration at '{}'", + path.canonicalize().unwrap().display() + ); + + Ok(config) } pub async fn load_from_file(path: &Path) -> Result { -- 2.47.2 From a628ff348edaa7b3cc38074ba0840f82479afe69 Mon Sep 17 00:00:00 2001 From: Andras Schmelczer Date: Sat, 7 Jun 2025 11:32:20 +0100 Subject: [PATCH 12/29] Send spans instead of indexes --- .../src/app_state/websocket/models.rs | 11 ++++++++-- backend/sync_server/src/server/websocket.rs | 20 +++++++++---------- .../src/services/types/ClientCursors.ts | 3 ++- .../types/CursorPositionFromClient.ts | 3 ++- .../src/services/types/CursorSpan.ts | 3 +++ scripts/update-api-types.sh | 4 ++-- 6 files changed, 28 insertions(+), 16 deletions(-) create mode 100644 frontend/sync-client/src/services/types/CursorSpan.ts diff --git a/backend/sync_server/src/app_state/websocket/models.rs b/backend/sync_server/src/app_state/websocket/models.rs index e6b1bade..0b8e1828 100644 --- a/backend/sync_server/src/app_state/websocket/models.rs +++ b/backend/sync_server/src/app_state/websocket/models.rs @@ -15,17 +15,24 @@ pub struct WebSocketHandshake { pub last_seen_vault_update_id: Option, } +#[derive(TS, Serialize, Deserialize, Clone, Debug)] +#[serde(rename_all = "camelCase")] +pub struct CursorSpan { + pub start: usize, + pub end: usize, +} + #[derive(TS, Deserialize, Clone, Debug)] #[serde(rename_all = "camelCase")] pub struct CursorPositionFromClient { - pub document_to_cursors: HashMap>, + pub document_to_cursors: HashMap>, } #[derive(TS, Serialize, Clone, Debug)] #[serde(rename_all = "camelCase")] pub struct ClientCursors { pub device_id: DeviceId, - pub cursors: HashMap>, + pub cursors: HashMap>, } #[derive(TS, Serialize, Clone, Debug)] diff --git a/backend/sync_server/src/server/websocket.rs b/backend/sync_server/src/server/websocket.rs index ea0e7fad..822c211c 100644 --- a/backend/sync_server/src/server/websocket.rs +++ b/backend/sync_server/src/server/websocket.rs @@ -59,9 +59,9 @@ async fn websocket( stream: WebSocket, vault_id: VaultId, ) -> Result<(), SyncServerError> { - let (mut sender, mut receiver) = stream.split(); + let (mut sender, mut websocket_receiver) = stream.split(); - let handshake = if let Some(Ok(message)) = receiver.next().await { + let handshake = if let Some(Ok(message)) = websocket_receiver.next().await { get_handshake(&state, &vault_id, message)? } else { return Err(unauthenticated_error(anyhow::anyhow!( @@ -69,7 +69,7 @@ async fn websocket( ))); }; - let mut rx = state.broadcasts.get_receiver(vault_id.clone()).await; + let mut broadcast_receiver = state.broadcasts.get_receiver(vault_id.clone()).await; send_update_over_websocket( &WebSocketServerMessage::VaultUpdate(WebSocketVaultUpdate { @@ -91,7 +91,7 @@ async fn websocket( let device_id = handshake.device_id.clone(); let mut send_task = tokio::spawn(async move { - while let Ok(update) = rx.recv().await { + while let Ok(update) = broadcast_receiver.recv().await { if Some(&device_id) == update.origin_device_id.as_ref() { continue; } @@ -103,10 +103,10 @@ async fn websocket( }); let device_id = handshake.device_id.clone(); - let mut recv_task = tokio::spawn(async move { - while let Some(Ok(Message::Text(message))) = receiver.next().await { + let mut receive_task = tokio::spawn(async move { + while let Some(Ok(Message::Text(message))) = websocket_receiver.next().await { let message: WebSocketClientMessage = serde_json::from_str(&message) - .context("Failed to parse message") + .context("Failed to parse WebSocket message from client") .map_err(server_error)?; match message { @@ -128,8 +128,8 @@ async fn websocket( }); tokio::select! { - _ = &mut send_task => recv_task.abort(), - _ = &mut recv_task => send_task.abort(), + _ = &mut send_task => receive_task.abort(), + _ = &mut receive_task => send_task.abort(), }; send_task @@ -137,7 +137,7 @@ async fn websocket( .context("WebSocket send task failed") .map_err(server_error)??; - recv_task + receive_task .await .context("WebSocket receive task failed") .map_err(server_error)??; diff --git a/frontend/sync-client/src/services/types/ClientCursors.ts b/frontend/sync-client/src/services/types/ClientCursors.ts index 4a91a1e7..87ebebdf 100644 --- a/frontend/sync-client/src/services/types/ClientCursors.ts +++ b/frontend/sync-client/src/services/types/ClientCursors.ts @@ -1,3 +1,4 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { CursorSpan } from "./CursorSpan"; -export type ClientCursors = { deviceId: string, cursors: { [key in string]?: Array }, }; +export type ClientCursors = { deviceId: string, cursors: { [key in string]?: Array }, }; diff --git a/frontend/sync-client/src/services/types/CursorPositionFromClient.ts b/frontend/sync-client/src/services/types/CursorPositionFromClient.ts index 5a60ec2f..87705d1c 100644 --- a/frontend/sync-client/src/services/types/CursorPositionFromClient.ts +++ b/frontend/sync-client/src/services/types/CursorPositionFromClient.ts @@ -1,3 +1,4 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { CursorSpan } from "./CursorSpan"; -export type CursorPositionFromClient = { documentToCursors: { [key in string]?: Array }, }; +export type CursorPositionFromClient = { documentToCursors: { [key in string]?: Array }, }; diff --git a/frontend/sync-client/src/services/types/CursorSpan.ts b/frontend/sync-client/src/services/types/CursorSpan.ts new file mode 100644 index 00000000..916019ce --- /dev/null +++ b/frontend/sync-client/src/services/types/CursorSpan.ts @@ -0,0 +1,3 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type CursorSpan = { start: number; end: number }; diff --git a/scripts/update-api-types.sh b/scripts/update-api-types.sh index 6fc7f6cd..3c8f788b 100755 --- a/scripts/update-api-types.sh +++ b/scripts/update-api-types.sh @@ -2,8 +2,6 @@ set -e -./scripts/utils/wait-for-server.sh - rm -rf backend/sync_server/bindings cd backend cargo test export_bindings @@ -11,5 +9,7 @@ cd - cp -r backend/sync_server/bindings/* frontend/sync-client/src/services/types/ +./scripts/utils/wait-for-server.sh + npm install -g openapi-typescript openapi-typescript http://localhost:3000/api.json --output frontend/sync-client/src/services/types/http-api.ts -- 2.47.2 From 3abe028d74be41c609f5ef72f5a34d049894bb5b Mon Sep 17 00:00:00 2001 From: Andras Schmelczer Date: Sat, 7 Jun 2025 12:09:02 +0100 Subject: [PATCH 13/29] Update rust --- backend/Cargo.toml | 1 - .../src/operation_transformation/merge_context.rs | 9 ++++----- backend/rust-toolchain.toml | 2 +- 3 files changed, 5 insertions(+), 7 deletions(-) diff --git a/backend/Cargo.toml b/backend/Cargo.toml index a12333c3..907b201b 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -43,7 +43,6 @@ inefficient_to_string = "warn" linkedlist = "warn" lossy_float_literal = "warn" macro_use_imports = "warn" -match_on_vec_items = "warn" match_wildcard_for_single_variants = "warn" mem_forget = "warn" needless_borrow = "warn" diff --git a/backend/reconcile/src/operation_transformation/merge_context.rs b/backend/reconcile/src/operation_transformation/merge_context.rs index d45f08ad..5cf0972d 100644 --- a/backend/reconcile/src/operation_transformation/merge_context.rs +++ b/backend/reconcile/src/operation_transformation/merge_context.rs @@ -62,12 +62,11 @@ where self.shift -= *deleted_character_count as i64; self.last_operation = None; } - } else if let Operation::Insert { .. } = last_operation { - if threshold_index + self.shift - last_operation.len() as i64 + } else if let Operation::Insert { .. } = last_operation + && threshold_index + self.shift - last_operation.len() as i64 > last_operation.end_index() as i64 - { - self.last_operation = None; - } + { + self.last_operation = None; } } } diff --git a/backend/rust-toolchain.toml b/backend/rust-toolchain.toml index 8e466642..0d5c6104 100644 --- a/backend/rust-toolchain.toml +++ b/backend/rust-toolchain.toml @@ -1,4 +1,4 @@ [toolchain] -channel = "nightly-2025-03-14" +channel = "nightly-2025-06-06" targets = [ "x86_64-unknown-linux-gnu", "x86_64-unknown-linux-musl" ] profile = "default" -- 2.47.2 From 0908a5b527f536b2a4fb6b2654f6e679825c42ac Mon Sep 17 00:00:00 2001 From: Andras Schmelczer Date: Sat, 7 Jun 2025 12:09:34 +0100 Subject: [PATCH 14/29] Remove cursor for disconnected client --- backend/sync_server/src/app_state/cursors.rs | 8 +++ .../src/app_state/websocket/utils.rs | 6 +- backend/sync_server/src/server/websocket.rs | 62 ++++++++++++------- 3 files changed, 52 insertions(+), 24 deletions(-) diff --git a/backend/sync_server/src/app_state/cursors.rs b/backend/sync_server/src/app_state/cursors.rs index d5aa01e4..a48aceec 100644 --- a/backend/sync_server/src/app_state/cursors.rs +++ b/backend/sync_server/src/app_state/cursors.rs @@ -100,6 +100,14 @@ impl Cursors { .await; } } + + pub async fn remove_cursors_of_device(&self, vault_id: &str, device_id: &str) { + let mut vault_to_cursors = self.vault_to_cursors.lock().await; + + if let Some(cursors) = vault_to_cursors.get_mut(vault_id) { + cursors.retain(|c| c.client_cursors.device_id != device_id); + } + } } #[derive(Clone, Debug)] diff --git a/backend/sync_server/src/app_state/websocket/utils.rs b/backend/sync_server/src/app_state/websocket/utils.rs index 7c4e2c05..cf337e39 100644 --- a/backend/sync_server/src/app_state/websocket/utils.rs +++ b/backend/sync_server/src/app_state/websocket/utils.rs @@ -12,12 +12,12 @@ use crate::{ server::auth::auth, }; -pub fn get_handshake( +pub fn get_authenticated_handshake( state: &AppState, vault_id: &VaultId, - message: Message, + message: Option, ) -> Result { - if let Message::Text(message) = message { + if let Some(Message::Text(message)) = message { let message: WebSocketClientMessage = serde_json::from_str(&message) .context("Failed to parse message") .map_err(server_error)?; diff --git a/backend/sync_server/src/server/websocket.rs b/backend/sync_server/src/server/websocket.rs index 822c211c..4a7d7833 100644 --- a/backend/sync_server/src/server/websocket.rs +++ b/backend/sync_server/src/server/websocket.rs @@ -20,10 +20,12 @@ use crate::{ CursorPositionFromServer, WebSocketClientMessage, WebSocketServerMessage, WebSocketVaultUpdate, }, - utils::{get_handshake, get_unseen_documents, send_update_over_websocket}, + utils::{ + get_authenticated_handshake, get_unseen_documents, send_update_over_websocket, + }, }, }, - errors::{SyncServerError, client_error, server_error, unauthenticated_error}, + errors::{SyncServerError, client_error, server_error}, utils::normalize::normalize, }; @@ -61,13 +63,15 @@ async fn websocket( ) -> Result<(), SyncServerError> { let (mut sender, mut websocket_receiver) = stream.split(); - let handshake = if let Some(Ok(message)) = websocket_receiver.next().await { - get_handshake(&state, &vault_id, message)? - } else { - return Err(unauthenticated_error(anyhow::anyhow!( - "Failed to authenticate due to invalid message" - ))); - }; + let handshake = get_authenticated_handshake( + &state, + &vault_id, + websocket_receiver + .next() + .await + .transpose() + .unwrap_or_default(), + )?; let mut broadcast_receiver = state.broadcasts.get_receiver(vault_id.clone()).await; @@ -103,6 +107,8 @@ async fn websocket( }); let device_id = handshake.device_id.clone(); + let vault_id_clone = vault_id.clone(); + let cursor_manager = state.cursors.clone(); let mut receive_task = tokio::spawn(async move { while let Some(Ok(Message::Text(message))) = websocket_receiver.next().await { let message: WebSocketClientMessage = serde_json::from_str(&message) @@ -116,9 +122,12 @@ async fn websocket( ))); } WebSocketClientMessage::CursorPositions(cursors) => { - state - .cursors - .update_cursors(vault_id.clone(), &device_id, cursors.document_to_cursors) + cursor_manager + .update_cursors( + vault_id_clone.clone(), + &device_id, + cursors.document_to_cursors, + ) .await; } } @@ -132,15 +141,26 @@ async fn websocket( _ = &mut receive_task => send_task.abort(), }; - send_task - .await - .context("WebSocket send task failed") - .map_err(server_error)??; + let result = { + send_task + .await + .context("WebSocket send task failed") + .map_err(server_error) + .and_then(|x| x)?; - receive_task - .await - .context("WebSocket receive task failed") - .map_err(server_error)??; + receive_task + .await + .context("WebSocket receive task failed") + .map_err(server_error) + .and_then(|x| x)?; - Ok(()) + Ok(()) + }; + + state + .cursors + .remove_cursors_of_device(&vault_id, &handshake.device_id) + .await; + + result } -- 2.47.2 From 4ca55768c56f26502f5ce900f76c9954d572cc3c Mon Sep 17 00:00:00 2001 From: Andras Schmelczer Date: Sat, 7 Jun 2025 17:15:16 +0100 Subject: [PATCH 15/29] Remove aide --- backend/Cargo.lock | 286 ------------------ backend/sync_server/Cargo.toml | 8 +- .../src/app_state/database/models.rs | 5 +- backend/sync_server/src/server.rs | 87 ++---- .../sync_server/src/server/create_document.rs | 80 +---- .../sync_server/src/server/delete_document.rs | 7 +- .../src/server/fetch_document_version.rs | 10 +- .../server/fetch_document_version_content.rs | 4 +- .../server/fetch_latest_document_version.rs | 10 +- .../src/server/fetch_latest_documents.rs | 13 +- backend/sync_server/src/server/ping.rs | 4 +- backend/sync_server/src/server/requests.rs | 31 +- backend/sync_server/src/server/responses.rs | 11 +- .../sync_server/src/server/update_document.rs | 85 +----- scripts/update-api-types.sh | 6 +- 15 files changed, 85 insertions(+), 562 deletions(-) diff --git a/backend/Cargo.lock b/backend/Cargo.lock index cebf93bf..bab8d80a 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -17,20 +17,6 @@ version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "512761e0bb2578dd7380c6baaa0f4ce03e84f95e960231d1dec8bf4d7d6e2627" -[[package]] -name = "ahash" -version = "0.8.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e89da841a80418a9b391ebaea17f5c112ffaaa96f621d2c285b5174da76b9011" -dependencies = [ - "cfg-if", - "getrandom 0.2.15", - "once_cell", - "serde", - "version_check", - "zerocopy 0.7.35", -] - [[package]] name = "aho-corasick" version = "1.1.3" @@ -40,41 +26,6 @@ dependencies = [ "memchr", ] -[[package]] -name = "aide" -version = "0.13.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5678d2978845ddb4bd736a026f467dd652d831e9e6254b0e41b07f7ee7523309" -dependencies = [ - "axum", - "axum-extra", - "bytes", - "cfg-if", - "http", - "indexmap 2.7.0", - "schemars", - "serde", - "serde_json", - "serde_qs", - "thiserror 1.0.69", - "tower-layer", - "tower-service", - "tracing", -] - -[[package]] -name = "aide-axum-typed-multipart" -version = "0.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b8f5c830a08754addfa31fa09e6c183bac8d2ae7bd007131f9eb84fcb87a40e" -dependencies = [ - "aide", - "axum", - "axum_typed_multipart", - "indexmap 2.7.0", - "schemars", -] - [[package]] name = "allocator-api2" version = "0.2.21" @@ -265,26 +216,6 @@ dependencies = [ "tower-service", ] -[[package]] -name = "axum-jsonschema" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fcffe29ca1b60172349fea781ec34441d598809bd227ccbb5bf5dc2879cd9c78" -dependencies = [ - "aide", - "async-trait", - "axum", - "http", - "http-body", - "itertools", - "jsonschema", - "schemars", - "serde", - "serde_json", - "serde_path_to_error", - "tracing", -] - [[package]] name = "axum-macros" version = "0.4.2" @@ -368,21 +299,6 @@ version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "230c5f1ca6a325a32553f8640d31ac9b49f2411e901e427570154868b46da4f7" -[[package]] -name = "bit-set" -version = "0.5.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0700ddab506f33b20a03b13996eccd309a48e5ff77d0d95926aa0210fb4e95f1" -dependencies = [ - "bit-vec", -] - -[[package]] -name = "bit-vec" -version = "0.6.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "349f9b6a179ed607305526ca489b34ad0a41aed5f7980fa90eb03160b69598fb" - [[package]] name = "bitflags" version = "2.6.0" @@ -407,12 +323,6 @@ version = "3.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "79296716171880943b8470b5f8d03aa55eb2e645a4874bdbb28adb49162e012c" -[[package]] -name = "bytecount" -version = "0.6.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ce89b21cab1437276d2650d57e971f9d548a2d9037cc231abdc0562b97498ce" - [[package]] name = "byteorder" version = "1.5.0" @@ -700,12 +610,6 @@ version = "0.15.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" -[[package]] -name = "dyn-clone" -version = "1.0.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d6ef0072f8a535281e4876be788938b528e9a1d43900b82c2569af7da799125" - [[package]] name = "either" version = "1.13.0" @@ -768,16 +672,6 @@ dependencies = [ "pin-project-lite", ] -[[package]] -name = "fancy-regex" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b95f7c0680e4142284cf8b22c14a476e87d61b004a3a0861872b32ef7ead40a2" -dependencies = [ - "bit-set", - "regex", -] - [[package]] name = "fastrand" version = "2.2.0" @@ -816,16 +710,6 @@ dependencies = [ "percent-encoding", ] -[[package]] -name = "fraction" -version = "0.13.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3027ae1df8d41b4bed2241c8fdad4acc1e7af60c8e17743534b545e77182d678" -dependencies = [ - "lazy_static", - "num", -] - [[package]] name = "futures" version = "0.3.31" @@ -943,10 +827,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c4567c8db10ae91089c99af84c68c38da3ec2f087c3f82960bcdbf3656b6f4d7" dependencies = [ "cfg-if", - "js-sys", "libc", "wasi 0.11.0+wasi-snapshot-preview1", - "wasm-bindgen", ] [[package]] @@ -1346,24 +1228,6 @@ version = "1.70.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf" -[[package]] -name = "iso8601" -version = "0.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "924e5d73ea28f59011fec52a0d12185d496a9b075d360657aed2a5707f701153" -dependencies = [ - "nom", -] - -[[package]] -name = "itertools" -version = "0.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba291022dbbd398a455acf126c1e341954079855bc60dfdda641363bd6922569" -dependencies = [ - "either", -] - [[package]] name = "itoa" version = "1.0.14" @@ -1380,34 +1244,6 @@ dependencies = [ "wasm-bindgen", ] -[[package]] -name = "jsonschema" -version = "0.17.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a071f4f7efc9a9118dfb627a0a94ef247986e1ab8606a4c806ae2b3aa3b6978" -dependencies = [ - "ahash", - "anyhow", - "base64 0.21.7", - "bytecount", - "fancy-regex", - "fraction", - "getrandom 0.2.15", - "iso8601", - "itoa", - "memchr", - "num-cmp", - "once_cell", - "parking_lot", - "percent-encoding", - "regex", - "serde", - "serde_json", - "time", - "url", - "uuid", -] - [[package]] name = "lazy_static" version = "1.5.0" @@ -1521,12 +1357,6 @@ dependencies = [ "walkdir", ] -[[package]] -name = "minimal-lexical" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" - [[package]] name = "miniz_oxide" version = "0.8.0" @@ -1564,16 +1394,6 @@ dependencies = [ "version_check", ] -[[package]] -name = "nom" -version = "7.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" -dependencies = [ - "memchr", - "minimal-lexical", -] - [[package]] name = "nu-ansi-term" version = "0.46.0" @@ -1584,30 +1404,6 @@ dependencies = [ "winapi", ] -[[package]] -name = "num" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23" -dependencies = [ - "num-bigint", - "num-complex", - "num-integer", - "num-iter", - "num-rational", - "num-traits", -] - -[[package]] -name = "num-bigint" -version = "0.4.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" -dependencies = [ - "num-integer", - "num-traits", -] - [[package]] name = "num-bigint-dig" version = "0.8.4" @@ -1625,21 +1421,6 @@ dependencies = [ "zeroize", ] -[[package]] -name = "num-cmp" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "63335b2e2c34fae2fb0aa2cecfd9f0832a1e24b3b32ecec612c3426d46dc8aaa" - -[[package]] -name = "num-complex" -version = "0.4.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" -dependencies = [ - "num-traits", -] - [[package]] name = "num-conv" version = "0.1.0" @@ -1666,17 +1447,6 @@ dependencies = [ "num-traits", ] -[[package]] -name = "num-rational" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" -dependencies = [ - "num-bigint", - "num-integer", - "num-traits", -] - [[package]] name = "num-traits" version = "0.2.19" @@ -2077,34 +1847,6 @@ dependencies = [ "regex", ] -[[package]] -name = "schemars" -version = "0.8.22" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3fbf2ae1b8bc8e02df939598064d22402220cd5bbcca1c76f7d6a310974d5615" -dependencies = [ - "bytes", - "chrono", - "dyn-clone", - "indexmap 2.7.0", - "schemars_derive", - "serde", - "serde_json", - "uuid", -] - -[[package]] -name = "schemars_derive" -version = "0.8.22" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32e265784ad618884abaea0600a9adf15393368d840e0222d101a072f3f7534d" -dependencies = [ - "proc-macro2", - "quote", - "serde_derive_internals", - "syn 2.0.90", -] - [[package]] name = "scoped-tls" version = "1.0.1" @@ -2137,17 +1879,6 @@ dependencies = [ "syn 2.0.90", ] -[[package]] -name = "serde_derive_internals" -version = "0.29.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.90", -] - [[package]] name = "serde_json" version = "1.0.140" @@ -2170,19 +1901,6 @@ dependencies = [ "serde", ] -[[package]] -name = "serde_qs" -version = "0.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd34f36fe4c5ba9654417139a9b3a20d2e1de6012ee678ad14d240c22c78d8d6" -dependencies = [ - "axum", - "futures", - "percent-encoding", - "serde", - "thiserror 1.0.69", -] - [[package]] name = "serde_urlencoded" version = "0.7.1" @@ -2610,12 +2328,9 @@ dependencies = [ name = "sync_server" version = "0.3.15" dependencies = [ - "aide", - "aide-axum-typed-multipart", "anyhow", "axum", "axum-extra", - "axum-jsonschema", "axum_typed_multipart", "bimap", "chrono", @@ -2626,7 +2341,6 @@ dependencies = [ "rand 0.9.0", "regex", "sanitize-filename", - "schemars", "serde", "serde_json", "serde_with", diff --git a/backend/sync_server/Cargo.toml b/backend/sync_server/Cargo.toml index 4aa1a373..3ca2c75a 100644 --- a/backend/sync_server/Cargo.toml +++ b/backend/sync_server/Cargo.toml @@ -18,22 +18,18 @@ log = { version = "0.4.27" } anyhow = { version = "1.0.98", features = ["backtrace"] } axum = { version = "0.7.4", features = ["ws", "macros", "tracing", "multipart"]} axum-extra = { version = "0.9.6", features = ["typed-header"] } -aide-axum-typed-multipart = "0.13.0" axum_typed_multipart = "0.11.0" tower-http = { version = "0.6.1", features = ["cors", "trace", "limit", "timeout"] } +tracing = "0.1.41" tracing-subscriber = { version = "0.3.19", features = ["fmt", "env-filter"]} -serde_yaml = "0.9.34" sqlx = { version = "0.8.6", features = ["sqlite", "runtime-tokio", "uuid", "chrono"] } chrono = { version = "0.4.41", features = ["serde"] } -aide = { version = "0.13.5", features = ["axum", "axum-ws", "scalar", "axum-headers"] } -schemars = { version = "0.8.22", features = ["chrono", "uuid1", "bytes"] } -tracing = "0.1.41" rand = "0.9.0" sanitize-filename = "0.6.0" -axum-jsonschema = { version = "0.8.0", features = ["aide"] } regex = "1.11.1" clap = { version = "4.5.38", features = ["derive"] } futures = "0.3.31" +serde_yaml = "0.9.34" serde_json = "1.0.140" clap-verbosity-flag = "3.0.3" bimap = "0.6.3" diff --git a/backend/sync_server/src/app_state/database/models.rs b/backend/sync_server/src/app_state/database/models.rs index a83202b9..bf555d0e 100644 --- a/backend/sync_server/src/app_state/database/models.rs +++ b/backend/sync_server/src/app_state/database/models.rs @@ -1,5 +1,4 @@ use chrono::{DateTime, Utc}; -use schemars::JsonSchema; use serde::Serialize; use sync_lib::bytes_to_base64; use ts_rs::TS; @@ -27,7 +26,7 @@ impl PartialEq for StoredDocumentVersion { fn eq(&self, other: &Self) -> bool { self.vault_update_id == other.vault_update_id } } -#[derive(TS, Debug, Clone, Serialize, JsonSchema)] +#[derive(TS, Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct DocumentVersionWithoutContent { #[ts(as = "i32")] @@ -59,7 +58,7 @@ impl From for DocumentVersionWithoutContent { } } -#[derive(Debug, Clone, Serialize, JsonSchema)] +#[derive(TS, Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct DocumentVersion { pub vault_update_id: VaultUpdateId, diff --git a/backend/sync_server/src/server.rs b/backend/sync_server/src/server.rs index 3b1f7201..efc5f071 100644 --- a/backend/sync_server/src/server.rs +++ b/backend/sync_server/src/server.rs @@ -12,29 +12,20 @@ mod responses; mod update_document; mod websocket; -use std::{ffi::OsString, sync::Arc, time::Duration}; +use std::{ffi::OsString, time::Duration}; -use aide::{ - axum::{ - ApiRouter, - routing::{delete, get, post, put}, - }, - openapi::{Info, OpenApi}, - scalar::Scalar, - transform::TransformOpenApi, -}; use anyhow::{Context as _, Result, anyhow}; use auth::auth_middleware; use axum::{ - Extension, Json, + Router, extract::{DefaultBodyLimit, Request}, http::{self, HeaderValue, Method}, middleware, response::IntoResponse, - routing::IntoMakeService, + routing::{IntoMakeService, delete, get, post, put}, }; use device_id_header::DEVICE_ID_HEADER_NAME; -use log::{error, info}; +use log::info; use tokio::signal; use tower_http::{ LatencyUnit, @@ -51,26 +42,20 @@ use tracing::{Level, info_span}; use crate::{ app_state::AppState, config::server_config::ServerConfig, - errors::{SerializedError, client_error, not_found_error}, + errors::{client_error, not_found_error}, }; pub async fn create_server(config_path: Option) -> Result<()> { - aide::r#gen::on_error(|err| error!("{err}")); - aide::r#gen::extract_schemas(true); - let app_state = AppState::try_new(config_path) .await .context("Failed to initialise app state")?; let server_config = app_state.config.server.clone(); - let mut api = create_open_api(); - let app = ApiRouter::new() + let app = Router::new() .nest("/", get_authed_routes(app_state.clone())) - .api_route("/vaults/:vault_id/ping", get(ping::ping)) + .route("/vaults/:vault_id/ping", get(ping::ping)) .route("/vaults/:vault_id/ws", get(websocket::websocket_handler)) - .route("/", Scalar::new("/api.json").axum_route()) - .route("/api.json", axum::routing::get(serve_api)) .layer(DefaultBodyLimit::disable()) .layer(RequestBodyLimitLayer::new( app_state.config.server.max_body_size_mb * 1024 * 1024, @@ -108,8 +93,6 @@ pub async fn create_server(config_path: Option) -> Result<()> { .on_failure(DefaultOnFailure::new().level(Level::ERROR)), ) .with_state(app_state) - .finish_api_with(&mut api, add_api_docs_error_example) - .layer(Extension(Arc::new(api))) // https://github.com/tamasfe/aide/blob/507f4a8822bc0c13cbda0f589da1e0f4cbcdb812/examples/example-axum/src/main.rs#L39 .fallback(handle_404) .fallback(handle_405) .into_make_service(); @@ -117,67 +100,33 @@ pub async fn create_server(config_path: Option) -> Result<()> { start_server(app, &server_config).await } -async fn serve_api(Extension(api): Extension>) -> impl IntoResponse { Json(api) } - -fn create_open_api() -> OpenApi { - OpenApi { - info: Info { - title: "VaultLink sync server".to_owned(), - summary: Some( - "Simple API for syncing documents between concurrent clients.".to_owned(), - ), - description: Some(include_str!("../README.md").to_owned()), - version: env!("CARGO_PKG_VERSION").to_owned(), - ..Info::default() - }, - ..OpenApi::default() - } -} - -fn add_api_docs_error_example(api: TransformOpenApi<'_>) -> TransformOpenApi<'_> { - api.default_response_with::, _>(|res| { - res.example(SerializedError { - message: "An error has occurred".to_owned(), - causes: vec![], - }) - }) -} - -fn get_authed_routes(app_state: AppState) -> ApiRouter { - ApiRouter::new() - .api_route( +fn get_authed_routes(app_state: AppState) -> Router { + Router::new() + .route( "/vaults/:vault_id/documents", get(fetch_latest_documents::fetch_latest_documents), ) - .api_route( + .route( "/vaults/:vault_id/documents", - post(create_document::create_document_multipart), + post(create_document::create_document), ) - .api_route( - "/vaults/:vault_id/documents/json", - post(create_document::create_document_json), - ) - .api_route( + .route( "/vaults/:vault_id/documents/:document_id", get(fetch_latest_document_version::fetch_latest_document_version), ) - .api_route( + .route( "/vaults/:vault_id/documents/:document_id", - put(update_document::update_document_multipart), + put(update_document::update_document), ) - .api_route( - "/vaults/:vault_id/documents/:document_id/json", - put(update_document::update_document_json), - ) - .api_route( + .route( "/vaults/:vault_id/documents/:document_id/versions/:version_id", put(fetch_document_version::fetch_document_version), ) - .api_route( + .route( "/vaults/:vault_id/documents/:document_id/versions/:version_id/content", put(fetch_document_version_content::fetch_document_version_content), ) - .api_route( + .route( "/vaults/:vault_id/documents/:document_id", delete(delete_document::delete_document), ) diff --git a/backend/sync_server/src/server/create_document.rs b/backend/sync_server/src/server/create_document.rs index 84f16d6a..7018d8cf 100644 --- a/backend/sync_server/src/server/create_document.rs +++ b/backend/sync_server/src/server/create_document.rs @@ -1,33 +1,24 @@ -use aide_axum_typed_multipart::TypedMultipart; use anyhow::Context as _; use axum::{ - Extension, + Extension, Json, extract::{Path, State}, }; use axum_extra::TypedHeader; -use axum_jsonschema::Json; -use schemars::JsonSchema; +use axum_typed_multipart::TypedMultipart; use serde::Deserialize; -use sync_lib::base64_to_bytes; -use super::{ - device_id_header::DeviceIdHeader, - requests::{CreateDocumentVersion, CreateDocumentVersionMultipart}, -}; +use super::{device_id_header::DeviceIdHeader, requests::CreateDocumentVersion}; use crate::{ app_state::{ AppState, - database::models::{ - DocumentId, DocumentVersionWithoutContent, StoredDocumentVersion, VaultId, - }, + database::models::{DocumentVersionWithoutContent, StoredDocumentVersion, VaultId}, }, config::user_config::User, errors::{SyncServerError, client_error, server_error}, utils::{normalize::normalize, sanitize_path::sanitize_path}, }; -// This is required for aide to infer the path parameter types and names -#[derive(Deserialize, JsonSchema)] +#[derive(Deserialize)] pub struct CreateDocumentPathParams { #[serde(deserialize_with = "normalize")] vault_id: VaultId, @@ -37,63 +28,12 @@ pub struct CreateDocumentPathParams { /// already. If a document with the same path exists, a new version is created /// with their content merged. #[axum::debug_handler] -pub async fn create_document_multipart( +pub async fn create_document( Path(CreateDocumentPathParams { vault_id }): Path, Extension(user): Extension, TypedHeader(device_id): TypedHeader, State(state): State, - TypedMultipart(axum_typed_multipart::TypedMultipart(request)): TypedMultipart< - CreateDocumentVersionMultipart, - >, -) -> Result, SyncServerError> { - internal_create_document( - user, - device_id, - state, - vault_id, - request.document_id, - request.relative_path, - request.content.contents.to_vec(), - ) - .await -} - -/// Create a new document in case a document with the same doesn't exist -/// already. If a document with the same path exists, a new version is created -/// with their content merged. -#[axum::debug_handler] -pub async fn create_document_json( - Path(CreateDocumentPathParams { vault_id }): Path, - Extension(user): Extension, - TypedHeader(device_id): TypedHeader, - State(state): State, - Json(request): Json, -) -> Result, SyncServerError> { - let content_bytes = base64_to_bytes(&request.content_base64) - .context("Failed to decode base64 content in request") - .map_err(client_error)?; - - internal_create_document( - user, - device_id, - state, - vault_id, - request.document_id, - request.relative_path, - content_bytes, - ) - .await -} - -#[allow(clippy::too_many_arguments)] -async fn internal_create_document( - user: User, - device_id: DeviceIdHeader, - state: AppState, - vault_id: VaultId, - document_id: Option, - relative_path: String, - content: Vec, + TypedMultipart(request): TypedMultipart, ) -> Result, SyncServerError> { let mut transaction = state .database @@ -101,7 +41,7 @@ async fn internal_create_document( .await .map_err(server_error)?; - let document_id = match document_id { + let document_id = match request.document_id { Some(document_id) => { let existing_version = state .database @@ -126,13 +66,13 @@ async fn internal_create_document( .await .map_err(server_error)?; - let sanitized_relative_path = sanitize_path(&relative_path); + let sanitized_relative_path = sanitize_path(&request.relative_path); let new_version = StoredDocumentVersion { vault_update_id: last_update_id + 1, document_id, relative_path: sanitized_relative_path, - content, + content: request.content.contents.to_vec(), updated_date: chrono::Utc::now(), is_deleted: false, user_id: user.name, diff --git a/backend/sync_server/src/server/delete_document.rs b/backend/sync_server/src/server/delete_document.rs index d27e97cd..5b7cd6ef 100644 --- a/backend/sync_server/src/server/delete_document.rs +++ b/backend/sync_server/src/server/delete_document.rs @@ -1,11 +1,9 @@ use anyhow::Context as _; use axum::{ - Extension, + Extension, Json, extract::{Path, State}, }; use axum_extra::TypedHeader; -use axum_jsonschema::Json; -use schemars::JsonSchema; use serde::Deserialize; use super::{device_id_header::DeviceIdHeader, requests::DeleteDocumentVersion}; @@ -21,8 +19,7 @@ use crate::{ utils::{normalize::normalize, sanitize_path::sanitize_path}, }; -// This is required for aide to infer the path parameter types and names -#[derive(Deserialize, JsonSchema)] +#[derive(Deserialize)] pub struct DeleteDocumentPathParams { #[serde(deserialize_with = "normalize")] vault_id: VaultId, diff --git a/backend/sync_server/src/server/fetch_document_version.rs b/backend/sync_server/src/server/fetch_document_version.rs index ee8f6c55..5b571a7b 100644 --- a/backend/sync_server/src/server/fetch_document_version.rs +++ b/backend/sync_server/src/server/fetch_document_version.rs @@ -1,7 +1,8 @@ use anyhow::anyhow; -use axum::extract::{Path, State}; -use axum_jsonschema::Json; -use schemars::JsonSchema; +use axum::{ + Json, + extract::{Path, State}, +}; use serde::Deserialize; use crate::{ @@ -13,8 +14,7 @@ use crate::{ utils::normalize::normalize, }; -// This is required for aide to infer the path parameter types and names -#[derive(Deserialize, JsonSchema)] +#[derive(Deserialize)] pub struct FetchDocumentVersionPathParams { #[serde(deserialize_with = "normalize")] vault_id: VaultId, diff --git a/backend/sync_server/src/server/fetch_document_version_content.rs b/backend/sync_server/src/server/fetch_document_version_content.rs index 50cacca1..a419b7bf 100644 --- a/backend/sync_server/src/server/fetch_document_version_content.rs +++ b/backend/sync_server/src/server/fetch_document_version_content.rs @@ -3,7 +3,6 @@ use axum::{ body::Bytes, extract::{Path, State}, }; -use schemars::JsonSchema; use serde::Deserialize; use crate::{ @@ -15,8 +14,7 @@ use crate::{ utils::normalize::normalize, }; -// This is required for aide to infer the path parameter types and names -#[derive(Deserialize, JsonSchema)] +#[derive(Deserialize)] pub struct FetchDocumentVersionContentPathParams { #[serde(deserialize_with = "normalize")] vault_id: VaultId, diff --git a/backend/sync_server/src/server/fetch_latest_document_version.rs b/backend/sync_server/src/server/fetch_latest_document_version.rs index 3b85ed37..07f07860 100644 --- a/backend/sync_server/src/server/fetch_latest_document_version.rs +++ b/backend/sync_server/src/server/fetch_latest_document_version.rs @@ -1,7 +1,8 @@ use anyhow::anyhow; -use axum::extract::{Path, State}; -use axum_jsonschema::Json; -use schemars::JsonSchema; +use axum::{ + Json, + extract::{Path, State}, +}; use serde::Deserialize; use crate::{ @@ -13,8 +14,7 @@ use crate::{ utils::normalize::normalize, }; -// This is required for aide to infer the path parameter types and names -#[derive(Deserialize, JsonSchema)] +#[derive(Deserialize)] pub struct FetchLatestDocumentVersionPathParams { #[serde(deserialize_with = "normalize")] vault_id: VaultId, diff --git a/backend/sync_server/src/server/fetch_latest_documents.rs b/backend/sync_server/src/server/fetch_latest_documents.rs index e78b7594..6101f55c 100644 --- a/backend/sync_server/src/server/fetch_latest_documents.rs +++ b/backend/sync_server/src/server/fetch_latest_documents.rs @@ -1,6 +1,7 @@ -use axum::extract::{Path, Query, State}; -use axum_jsonschema::Json; -use schemars::JsonSchema; +use axum::{ + Json, + extract::{Path, Query, State}, +}; use serde::Deserialize; use super::responses::FetchLatestDocumentsResponse; @@ -13,15 +14,13 @@ use crate::{ utils::normalize::normalize, }; -// This is required for aide to infer the path parameter types and names -#[derive(Deserialize, JsonSchema)] +#[derive(Deserialize)] pub struct FetchLatestDocumentsPathParams { #[serde(deserialize_with = "normalize")] vault_id: VaultId, } -// This is required for aide to infer the path parameter types and names -#[derive(Deserialize, JsonSchema)] +#[derive(Deserialize)] pub struct QueryParams { since_update_id: Option, } diff --git a/backend/sync_server/src/server/ping.rs b/backend/sync_server/src/server/ping.rs index 96a8d82a..620ef0d4 100644 --- a/backend/sync_server/src/server/ping.rs +++ b/backend/sync_server/src/server/ping.rs @@ -6,7 +6,6 @@ use axum_extra::{ TypedHeader, headers::{Authorization, authorization::Bearer}, }; -use schemars::JsonSchema; use serde::Deserialize; use super::{auth::auth, responses::PingResponse}; @@ -16,8 +15,7 @@ use crate::{ utils::normalize::normalize, }; -// This is required for aide to infer the path parameter types and names -#[derive(Deserialize, JsonSchema)] +#[derive(Deserialize)] pub struct PingPathParams { #[serde(deserialize_with = "normalize")] vault_id: VaultId, diff --git a/backend/sync_server/src/server/requests.rs b/backend/sync_server/src/server/requests.rs index 89820dbe..9d1e478b 100644 --- a/backend/sync_server/src/server/requests.rs +++ b/backend/sync_server/src/server/requests.rs @@ -1,13 +1,12 @@ -use aide_axum_typed_multipart::FieldData; use axum::body::Bytes; -use axum_typed_multipart::TryFromMultipart; -use schemars::JsonSchema; +use axum_typed_multipart::{FieldData, TryFromMultipart}; use serde::{self, Deserialize}; +use ts_rs::TS; use crate::app_state::database::models::{DocumentId, VaultUpdateId}; -#[derive(Debug, Deserialize, JsonSchema)] -#[serde(rename_all = "camelCase")] +#[derive(TS, Debug, TryFromMultipart)] +#[ts(export)] pub struct CreateDocumentVersion { /// The client can decide the document id (if it wishes to) in order /// to help with syncing. If the client does not provide a document id, @@ -15,36 +14,26 @@ pub struct CreateDocumentVersion { /// it must not already exist in the database. pub document_id: Option, pub relative_path: String, - pub content_base64: String, -} -#[derive(Debug, TryFromMultipart, JsonSchema)] -pub struct CreateDocumentVersionMultipart { - pub document_id: Option, - pub relative_path: String, + #[ts(as = "Vec")] #[form_data(limit = "unlimited")] pub content: FieldData, } -#[derive(Debug, Deserialize, JsonSchema)] -#[serde(rename_all = "camelCase")] +#[derive(TS, Debug, TryFromMultipart)] +#[ts(export)] pub struct UpdateDocumentVersion { pub parent_version_id: VaultUpdateId, pub relative_path: String, - pub content_base64: String, -} -#[derive(Debug, TryFromMultipart, JsonSchema)] -#[serde(rename_all = "camelCase")] -pub struct UpdateDocumentVersionMultipart { - pub parent_version_id: VaultUpdateId, - pub relative_path: String, + #[ts(as = "Vec")] #[form_data(limit = "unlimited")] pub content: FieldData, } -#[derive(Debug, Deserialize, JsonSchema)] +#[derive(TS, Debug, Deserialize)] #[serde(rename_all = "camelCase")] +#[ts(export)] pub struct DeleteDocumentVersion { pub relative_path: String, } diff --git a/backend/sync_server/src/server/responses.rs b/backend/sync_server/src/server/responses.rs index 993bc7e7..5cfaa5d5 100644 --- a/backend/sync_server/src/server/responses.rs +++ b/backend/sync_server/src/server/responses.rs @@ -1,13 +1,14 @@ -use schemars::JsonSchema; use serde::{self, Serialize}; +use ts_rs::TS; use crate::app_state::database::models::{ DocumentVersion, DocumentVersionWithoutContent, VaultUpdateId, }; /// Response to a ping request. -#[derive(Debug, Clone, Serialize, JsonSchema)] +#[derive(TS, Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] +#[ts(export)] pub struct PingResponse { /// Semantic version of the server. pub server_version: String, @@ -18,8 +19,9 @@ pub struct PingResponse { } /// Response to a fetch latest documents request. -#[derive(Debug, Clone, Serialize, JsonSchema)] +#[derive(TS, Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] +#[ts(export)] pub struct FetchLatestDocumentsResponse { pub latest_documents: Vec, @@ -28,8 +30,9 @@ pub struct FetchLatestDocumentsResponse { } /// Response to an update document request. -#[derive(Debug, Clone, Serialize, JsonSchema)] +#[derive(TS, Debug, Clone, Serialize)] #[serde(tag = "type")] +#[ts(export)] pub enum DocumentUpdateResponse { /// Returned when the created/updated document's content is the same as was /// sent in the create/update request and thus the response doesn't contain diff --git a/backend/sync_server/src/server/update_document.rs b/backend/sync_server/src/server/update_document.rs index a784dad4..a3ab25e1 100644 --- a/backend/sync_server/src/server/update_document.rs +++ b/backend/sync_server/src/server/update_document.rs @@ -1,33 +1,29 @@ -use aide_axum_typed_multipart::TypedMultipart; use anyhow::{Context as _, anyhow}; use axum::{ - Extension, + Extension, Json, extract::{Path, State}, }; use axum_extra::TypedHeader; -use axum_jsonschema::Json; +use axum_typed_multipart::TypedMultipart; use log::info; -use schemars::JsonSchema; use serde::Deserialize; -use sync_lib::{base64_to_bytes, is_file_type_mergable, merge}; +use sync_lib::{is_file_type_mergable, merge}; use super::{ - device_id_header::DeviceIdHeader, - requests::{UpdateDocumentVersion, UpdateDocumentVersionMultipart}, + device_id_header::DeviceIdHeader, requests::UpdateDocumentVersion, responses::DocumentUpdateResponse, }; use crate::{ app_state::{ AppState, - database::models::{DocumentId, StoredDocumentVersion, VaultId, VaultUpdateId}, + database::models::{DocumentId, StoredDocumentVersion, VaultId}, }, config::user_config::User, - errors::{SyncServerError, client_error, not_found_error, server_error}, + errors::{SyncServerError, not_found_error, server_error}, utils::{dedup_paths::dedup_paths, normalize::normalize, sanitize_path::sanitize_path}, }; -// This is required for aide to infer the path parameter types and names -#[derive(Deserialize, JsonSchema)] +#[derive(Deserialize)] pub struct UpdateDocumentPathParams { #[serde(deserialize_with = "normalize")] vault_id: VaultId, @@ -36,7 +32,8 @@ pub struct UpdateDocumentPathParams { } #[axum::debug_handler] -pub async fn update_document_multipart( +#[allow(clippy::too_many_lines)] +pub async fn update_document( Path(UpdateDocumentPathParams { vault_id, document_id, @@ -44,79 +41,25 @@ pub async fn update_document_multipart( Extension(user): Extension, TypedHeader(device_id): TypedHeader, State(state): State, - TypedMultipart(axum_typed_multipart::TypedMultipart(request)): TypedMultipart< - UpdateDocumentVersionMultipart, - >, -) -> Result, SyncServerError> { - internal_update_document( - user, - device_id, - state, - vault_id, - document_id, - request.parent_version_id, - request.relative_path, - request.content.contents.to_vec(), - ) - .await -} - -#[axum::debug_handler] -pub async fn update_document_json( - Path(UpdateDocumentPathParams { - vault_id, - document_id, - }): Path, - Extension(user): Extension, - TypedHeader(device_id): TypedHeader, - State(state): State, - Json(request): Json, -) -> Result, SyncServerError> { - let content_bytes = base64_to_bytes(&request.content_base64) - .context("Failed to decode base64 content in request") - .map_err(client_error)?; - - internal_update_document( - user, - device_id, - state, - vault_id, - document_id, - request.parent_version_id, - request.relative_path, - content_bytes, - ) - .await -} - -#[allow(clippy::too_many_arguments, clippy::too_many_lines)] -async fn internal_update_document( - user: User, - device_id: DeviceIdHeader, - state: AppState, - vault_id: VaultId, - document_id: DocumentId, - parent_version_id: VaultUpdateId, - relative_path: String, - content: Vec, + TypedMultipart(request): TypedMultipart, ) -> Result, SyncServerError> { // No need for a transaction as document versions are immutable let parent_document = state .database - .get_document_version(&vault_id, parent_version_id, None) + .get_document_version(&vault_id, request.parent_version_id, None) .await .map_err(server_error)? .map_or_else( || { Err(not_found_error(anyhow!( "Parent version with id `{}` not found", - parent_version_id + request.parent_version_id ))) }, Ok, )?; - let sanitized_relative_path = sanitize_path(&relative_path); + let sanitized_relative_path = sanitize_path(&request.relative_path); let mut transaction = state .database @@ -156,6 +99,8 @@ async fn internal_update_document( ))); } + let content = request.content.contents.to_vec(); + // Return the latest version if the content and path are the same as the latest // version if content == latest_version.content && sanitized_relative_path == latest_version.relative_path diff --git a/scripts/update-api-types.sh b/scripts/update-api-types.sh index 3c8f788b..aea8a890 100755 --- a/scripts/update-api-types.sh +++ b/scripts/update-api-types.sh @@ -3,13 +3,9 @@ set -e rm -rf backend/sync_server/bindings + cd backend cargo test export_bindings cd - cp -r backend/sync_server/bindings/* frontend/sync-client/src/services/types/ - -./scripts/utils/wait-for-server.sh - -npm install -g openapi-typescript -openapi-typescript http://localhost:3000/api.json --output frontend/sync-client/src/services/types/http-api.ts -- 2.47.2 From ebbececdc93a2b7551fbe3796df27212a2fe5811 Mon Sep 17 00:00:00 2001 From: Andras Schmelczer Date: Sat, 7 Jun 2025 17:15:56 +0100 Subject: [PATCH 16/29] Nicer log line on ws disconnect --- backend/sync_server/src/errors.rs | 23 ++++++-------- backend/sync_server/src/server/websocket.rs | 35 +++++++++++++-------- 2 files changed, 31 insertions(+), 27 deletions(-) diff --git a/backend/sync_server/src/errors.rs b/backend/sync_server/src/errors.rs index a16f7137..aa1421ef 100644 --- a/backend/sync_server/src/errors.rs +++ b/backend/sync_server/src/errors.rs @@ -1,13 +1,11 @@ use std::fmt::Display; -use aide::OperationOutput; use axum::{ Json, http::StatusCode, response::{IntoResponse, Response}, }; -use log::{error, info}; -use schemars::JsonSchema; +use log::{debug, error}; use serde::Serialize; use thiserror::Error; @@ -45,7 +43,7 @@ impl SyncServerError { } } -#[derive(Debug, Clone, Serialize, JsonSchema)] +#[derive(Debug, Clone, Serialize)] pub struct SerializedError { pub message: String, pub causes: Vec, @@ -96,35 +94,32 @@ impl From<&anyhow::Error> for SerializedError { } } -impl OperationOutput for SyncServerError { - type Inner = Self; -} - -pub const fn init_error(error: anyhow::Error) -> SyncServerError { +pub fn init_error(error: anyhow::Error) -> SyncServerError { + debug!("Initialization error: {error:?}"); SyncServerError::InitError(error) } pub fn server_error(error: anyhow::Error) -> SyncServerError { - error!("Server error: {error:?}"); + debug!("Server error: {error:?}"); SyncServerError::ServerError(error) } pub fn client_error(error: anyhow::Error) -> SyncServerError { - info!("Client error: {error:?}"); + debug!("Client error: {error:?}"); SyncServerError::ClientError(error) } pub fn not_found_error(error: anyhow::Error) -> SyncServerError { - info!("Not found: {error:?}"); + debug!("Not found: {error:?}"); SyncServerError::NotFound(error) } pub fn unauthenticated_error(error: anyhow::Error) -> SyncServerError { - info!("Unauthenticated user: {error:?}"); + debug!("Unauthenticated user: {error:?}"); SyncServerError::Unauthenticated(error) } pub fn permission_denied_error(error: anyhow::Error) -> SyncServerError { - info!("Permission denied: {error:?}"); + debug!("Permission denied: {error:?}"); SyncServerError::PermissionDeniedError(error) } diff --git a/backend/sync_server/src/server/websocket.rs b/backend/sync_server/src/server/websocket.rs index 4a7d7833..cfe7f8f9 100644 --- a/backend/sync_server/src/server/websocket.rs +++ b/backend/sync_server/src/server/websocket.rs @@ -7,8 +7,7 @@ use axum::{ response::Response, }; use futures::stream::StreamExt; -use log::{error, info, warn}; -use schemars::JsonSchema; +use log::{debug, info}; use serde::Deserialize; use crate::{ @@ -29,8 +28,7 @@ use crate::{ utils::normalize::normalize, }; -// This is required for aide to infer the path parameter types and names -#[derive(Deserialize, JsonSchema)] +#[derive(Deserialize)] pub struct WebSocketPathParams { #[serde(deserialize_with = "normalize")] vault_id: VaultId, @@ -50,10 +48,8 @@ async fn websocket_wrapped(state: AppState, stream: WebSocket, vault_id: VaultId let result = websocket(state, stream, vault_id.clone()).await; if let Err(err) = result { - error!("WebSocket connection error on vault '{vault_id}': {err}"); + debug!("WebSocket connection error on vault '{vault_id}': {err}"); } - - warn!("WebSocket connection closed on vault '{vault_id}'"); } async fn websocket( @@ -73,6 +69,11 @@ async fn websocket( .unwrap_or_default(), )?; + info!( + "WebSocket handshake successful for vault '{vault_id}' for '{}'", + handshake.device_id + ); + let mut broadcast_receiver = state.broadcasts.get_receiver(vault_id.clone()).await; send_update_over_websocket( @@ -141,26 +142,34 @@ async fn websocket( _ = &mut receive_task => send_task.abort(), }; - let result = { + let result: Result<(), SyncServerError> = (async { send_task .await .context("WebSocket send task failed") - .map_err(server_error) - .and_then(|x| x)?; + .map_err(client_error) + .and_then(|err| err)?; receive_task .await .context("WebSocket receive task failed") - .map_err(server_error) - .and_then(|x| x)?; + .map_err(client_error) + .and_then(|err| err)?; Ok(()) - }; + }) + .await; state .cursors .remove_cursors_of_device(&vault_id, &handshake.device_id) .await; + if result.is_err() { + info!( + "WebSocket disconnected on vault '{vault_id}' for '{}'", + handshake.device_id + ); + } + result } -- 2.47.2 From 9753eaeff5a69d42d567534629a18c1b3c3f7bd9 Mon Sep 17 00:00:00 2001 From: Andras Schmelczer Date: Sat, 7 Jun 2025 17:16:50 +0100 Subject: [PATCH 17/29] Generate TS API types --- .../src/services/types/CreateDocumentVersion.ts | 10 ++++++++++ .../sync-client/src/services/types/CursorSpan.ts | 2 +- .../src/services/types/DeleteDocumentVersion.ts | 3 +++ .../src/services/types/DocumentUpdateResponse.ts | 8 ++++++++ .../src/services/types/DocumentVersion.ts | 3 +++ .../types/FetchLatestDocumentsResponse.ts | 11 +++++++++++ .../src/services/types/PingResponse.ts | 15 +++++++++++++++ .../src/services/types/UpdateDocumentVersion.ts | 3 +++ 8 files changed, 54 insertions(+), 1 deletion(-) create mode 100644 frontend/sync-client/src/services/types/CreateDocumentVersion.ts create mode 100644 frontend/sync-client/src/services/types/DeleteDocumentVersion.ts create mode 100644 frontend/sync-client/src/services/types/DocumentUpdateResponse.ts create mode 100644 frontend/sync-client/src/services/types/DocumentVersion.ts create mode 100644 frontend/sync-client/src/services/types/FetchLatestDocumentsResponse.ts create mode 100644 frontend/sync-client/src/services/types/PingResponse.ts create mode 100644 frontend/sync-client/src/services/types/UpdateDocumentVersion.ts diff --git a/frontend/sync-client/src/services/types/CreateDocumentVersion.ts b/frontend/sync-client/src/services/types/CreateDocumentVersion.ts new file mode 100644 index 00000000..4823105b --- /dev/null +++ b/frontend/sync-client/src/services/types/CreateDocumentVersion.ts @@ -0,0 +1,10 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type CreateDocumentVersion = { +/** + * The client can decide the document id (if it wishes to) in order + * to help with syncing. If the client does not provide a document id, + * the server will generate one. If the client provides a document id + * it must not already exist in the database. + */ +document_id: string | null, relative_path: string, content: Array, }; diff --git a/frontend/sync-client/src/services/types/CursorSpan.ts b/frontend/sync-client/src/services/types/CursorSpan.ts index 916019ce..d0bce6ea 100644 --- a/frontend/sync-client/src/services/types/CursorSpan.ts +++ b/frontend/sync-client/src/services/types/CursorSpan.ts @@ -1,3 +1,3 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -export type CursorSpan = { start: number; end: number }; +export type CursorSpan = { start: number, end: number, }; diff --git a/frontend/sync-client/src/services/types/DeleteDocumentVersion.ts b/frontend/sync-client/src/services/types/DeleteDocumentVersion.ts new file mode 100644 index 00000000..6244f7ab --- /dev/null +++ b/frontend/sync-client/src/services/types/DeleteDocumentVersion.ts @@ -0,0 +1,3 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type DeleteDocumentVersion = { relativePath: string, }; diff --git a/frontend/sync-client/src/services/types/DocumentUpdateResponse.ts b/frontend/sync-client/src/services/types/DocumentUpdateResponse.ts new file mode 100644 index 00000000..418117e6 --- /dev/null +++ b/frontend/sync-client/src/services/types/DocumentUpdateResponse.ts @@ -0,0 +1,8 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { DocumentVersion } from "./DocumentVersion"; +import type { DocumentVersionWithoutContent } from "./DocumentVersionWithoutContent"; + +/** + * Response to an update document request. + */ +export type DocumentUpdateResponse = { "type": "FastForwardUpdate" } & DocumentVersionWithoutContent | { "type": "MergingUpdate" } & DocumentVersion; diff --git a/frontend/sync-client/src/services/types/DocumentVersion.ts b/frontend/sync-client/src/services/types/DocumentVersion.ts new file mode 100644 index 00000000..0bdad5c9 --- /dev/null +++ b/frontend/sync-client/src/services/types/DocumentVersion.ts @@ -0,0 +1,3 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type DocumentVersion = { vaultUpdateId: bigint, documentId: string, relativePath: string, updatedDate: string, contentBase64: string, isDeleted: boolean, userId: string, deviceId: string, }; diff --git a/frontend/sync-client/src/services/types/FetchLatestDocumentsResponse.ts b/frontend/sync-client/src/services/types/FetchLatestDocumentsResponse.ts new file mode 100644 index 00000000..ce572684 --- /dev/null +++ b/frontend/sync-client/src/services/types/FetchLatestDocumentsResponse.ts @@ -0,0 +1,11 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { DocumentVersionWithoutContent } from "./DocumentVersionWithoutContent"; + +/** + * Response to a fetch latest documents request. + */ +export type FetchLatestDocumentsResponse = { latestDocuments: Array, +/** + * The update ID of the latest document in the response. + */ +lastUpdateId: bigint, }; diff --git a/frontend/sync-client/src/services/types/PingResponse.ts b/frontend/sync-client/src/services/types/PingResponse.ts new file mode 100644 index 00000000..6d3cba6e --- /dev/null +++ b/frontend/sync-client/src/services/types/PingResponse.ts @@ -0,0 +1,15 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * Response to a ping request. + */ +export type PingResponse = { +/** + * Semantic version of the server. + */ +serverVersion: string, +/** + * Whether the client is authenticated based on the sent Authorization + * header. + */ +isAuthenticated: boolean, }; diff --git a/frontend/sync-client/src/services/types/UpdateDocumentVersion.ts b/frontend/sync-client/src/services/types/UpdateDocumentVersion.ts new file mode 100644 index 00000000..482e281a --- /dev/null +++ b/frontend/sync-client/src/services/types/UpdateDocumentVersion.ts @@ -0,0 +1,3 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type UpdateDocumentVersion = { parent_version_id: bigint, relative_path: string, content: Array, }; -- 2.47.2 From 7956a92bec236cf9dd383408b6ee62d232314f0d Mon Sep 17 00:00:00 2001 From: Andras Schmelczer Date: Sat, 7 Jun 2025 21:33:47 +0100 Subject: [PATCH 18/29] Remove openapi from frontend --- backend/sync_server/src/errors.rs | 17 +- frontend/package-lock.json | 245 +------ frontend/sync-client/package.json | 4 +- .../src/services/connection-status.ts | 7 +- .../sync-client/src/services/sync-service.ts | 277 +++----- .../src/services/types/DocumentVersion.ts | 2 +- .../src/services/types/SerializedError.ts | 3 + .../src/services/types/http-api.ts | 648 ------------------ .../sync-client/src/sync-operations/syncer.ts | 4 +- .../sync-operations/unrestricted-syncer.ts | 12 +- 10 files changed, 157 insertions(+), 1062 deletions(-) create mode 100644 frontend/sync-client/src/services/types/SerializedError.ts delete mode 100644 frontend/sync-client/src/services/types/http-api.ts diff --git a/backend/sync_server/src/errors.rs b/backend/sync_server/src/errors.rs index aa1421ef..987c3011 100644 --- a/backend/sync_server/src/errors.rs +++ b/backend/sync_server/src/errors.rs @@ -8,6 +8,7 @@ use axum::{ use log::{debug, error}; use serde::Serialize; use thiserror::Error; +use ts_rs::TS; #[derive(Error, Debug)] pub enum SyncServerError { @@ -43,8 +44,11 @@ impl SyncServerError { } } -#[derive(Debug, Clone, Serialize)] +#[derive(TS, Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +#[ts(export)] pub struct SerializedError { + pub error_type: &'static str, pub message: String, pub causes: Vec, } @@ -88,6 +92,17 @@ impl From<&anyhow::Error> for SerializedError { } SerializedError { + error_type: error.downcast_ref::().map_or( + "UnknownError", + |e| match e { + SyncServerError::InitError(_) => "InitError", + SyncServerError::ClientError(_) => "ClientError", + SyncServerError::ServerError(_) => "ServerError", + SyncServerError::NotFound(_) => "NotFound", + SyncServerError::Unauthenticated(_) => "Unauthenticated", + SyncServerError::PermissionDeniedError(_) => "PermissionDeniedError", + }, + ), message: error.to_string(), causes, } diff --git a/frontend/package-lock.json b/frontend/package-lock.json index a7b5cc01..a2220c54 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -43,6 +43,7 @@ "version": "7.26.2", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.26.2.tgz", "integrity": "sha512-RJlIHRueQgwWitWgF8OdFYGZX328Ax5BCemNGlqHfplnRT9ESi8JkFlvaVYbS+UubVY6dpv87Fs2u5M29iNFVQ==", + "dev": true, "license": "MIT", "dependencies": { "@babel/helper-validator-identifier": "^7.25.9", @@ -204,6 +205,7 @@ "version": "7.25.9", "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.25.9.tgz", "integrity": "sha512-Ed61U6XJc3CVRfkERJWDz4dJwKe7iLmmJsbOGu9wSloNSFttHV0I8g6UAgb7qnK5ly5bGLPd4oXZlxCdANBOWQ==", + "dev": true, "license": "MIT", "engines": { "node": ">=6.9.0" @@ -1620,76 +1622,6 @@ "url": "https://opencollective.com/parcel" } }, - "node_modules/@redocly/ajv": { - "version": "8.11.2", - "resolved": "https://registry.npmjs.org/@redocly/ajv/-/ajv-8.11.2.tgz", - "integrity": "sha512-io1JpnwtIcvojV7QKDUSIuMN/ikdOUd1ReEnUnMKGfDVridQZ31J0MmIuqwuRjWDZfmvr+Q0MqCcfHM2gTivOg==", - "license": "MIT", - "dependencies": { - "fast-deep-equal": "^3.1.1", - "json-schema-traverse": "^1.0.0", - "require-from-string": "^2.0.2", - "uri-js-replace": "^1.0.1" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, - "node_modules/@redocly/ajv/node_modules/json-schema-traverse": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", - "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", - "license": "MIT" - }, - "node_modules/@redocly/config": { - "version": "0.22.1", - "resolved": "https://registry.npmjs.org/@redocly/config/-/config-0.22.1.tgz", - "integrity": "sha512-1CqQfiG456v9ZgYBG9xRQHnpXjt8WoSnDwdkX6gxktuK69v2037hTAR1eh0DGIqpZ1p4k82cGH8yTNwt7/pI9g==", - "license": "MIT" - }, - "node_modules/@redocly/openapi-core": { - "version": "1.34.0", - "resolved": "https://registry.npmjs.org/@redocly/openapi-core/-/openapi-core-1.34.0.tgz", - "integrity": "sha512-Ji00EiLQRXq0pJIz5pAjGF9MfQvQVsQehc6uIis6sqat8tG/zh25Zi64w6HVGEDgJEzUeq/CuUlD0emu3Hdaqw==", - "license": "MIT", - "dependencies": { - "@redocly/ajv": "^8.11.2", - "@redocly/config": "^0.22.0", - "colorette": "^1.2.0", - "https-proxy-agent": "^7.0.5", - "js-levenshtein": "^1.1.6", - "js-yaml": "^4.1.0", - "minimatch": "^5.0.1", - "pluralize": "^8.0.0", - "yaml-ast-parser": "0.0.43" - }, - "engines": { - "node": ">=18.17.0", - "npm": ">=9.5.0" - } - }, - "node_modules/@redocly/openapi-core/node_modules/brace-expansion": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", - "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0" - } - }, - "node_modules/@redocly/openapi-core/node_modules/minimatch": { - "version": "5.1.6", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz", - "integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==", - "license": "ISC", - "dependencies": { - "brace-expansion": "^2.0.1" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/@sinclair/typebox": { "version": "0.27.8", "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.8.tgz", @@ -2375,15 +2307,6 @@ "node": ">=8.9" } }, - "node_modules/agent-base": { - "version": "7.1.3", - "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.3.tgz", - "integrity": "sha512-jRR5wdylq8CkOe6hei19GGZnxM6rBGwFl3Bg0YItGDimvjGtAvdZk4Pu6Cl4u4Igsws4a1fd1Vq3ezrhn4KmFw==", - "license": "MIT", - "engines": { - "node": ">= 14" - } - }, "node_modules/ajv": { "version": "6.12.6", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", @@ -2453,15 +2376,6 @@ "ajv": "^6.9.1" } }, - "node_modules/ansi-colors": { - "version": "4.1.3", - "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.3.tgz", - "integrity": "sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==", - "license": "MIT", - "engines": { - "node": ">=6" - } - }, "node_modules/ansi-escapes": { "version": "4.3.2", "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz", @@ -2522,6 +2436,7 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, "license": "Python-2.0" }, "node_modules/async": { @@ -2882,12 +2797,6 @@ "node": ">=8" } }, - "node_modules/change-case": { - "version": "5.4.4", - "resolved": "https://registry.npmjs.org/change-case/-/change-case-5.4.4.tgz", - "integrity": "sha512-HRQyTk2/YPEkt9TnUPbOpr64Uw3KOicFWPVBb+xiHvd6eBx/qPr9xqfBFDT8P2vWsvvz4jbEkfDe71W3VyNu2w==", - "license": "MIT" - }, "node_modules/char-regex": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/char-regex/-/char-regex-1.0.2.tgz", @@ -3015,12 +2924,6 @@ "dev": true, "license": "MIT" }, - "node_modules/colorette": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/colorette/-/colorette-1.4.0.tgz", - "integrity": "sha512-Y2oEozpomLn7Q3HFP7dpww7AtMJplbM9lGZP6RDfHqmbeRjiwRg4n6VM6j4KLmRke85uWEI7JqF17f3pqdRA0g==", - "license": "MIT" - }, "node_modules/commander": { "version": "2.20.3", "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", @@ -3169,6 +3072,7 @@ "version": "4.4.0", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz", "integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==", + "dev": true, "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -3671,6 +3575,7 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, "license": "MIT" }, "node_modules/fast-glob": { @@ -4146,19 +4051,6 @@ "dev": true, "license": "MIT" }, - "node_modules/https-proxy-agent": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", - "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", - "license": "MIT", - "dependencies": { - "agent-base": "^7.1.2", - "debug": "4" - }, - "engines": { - "node": ">= 14" - } - }, "node_modules/human-signals": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", @@ -4246,18 +4138,6 @@ "node": ">=0.8.19" } }, - "node_modules/index-to-position": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/index-to-position/-/index-to-position-1.0.0.tgz", - "integrity": "sha512-sCO7uaLVhRJ25vz1o8s9IFM3nVS4DkuQnyjMwiQPKvQuBYBDmb8H7zx8ki7nVh4HJQOdVWebyvLE0qt+clruxA==", - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/inflight": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", @@ -5076,25 +4956,18 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, - "node_modules/js-levenshtein": { - "version": "1.1.6", - "resolved": "https://registry.npmjs.org/js-levenshtein/-/js-levenshtein-1.1.6.tgz", - "integrity": "sha512-X2BB11YZtrRqY4EnQcLX5Rh373zbK4alC1FW7D7MBhL2gtcC17cTnr6DmfHZeS0s2rTHjUTMMHfG7gO8SSdw+g==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, "license": "MIT" }, "node_modules/js-yaml": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", + "dev": true, "license": "MIT", "dependencies": { "argparse": "^2.0.1" @@ -5515,6 +5388,7 @@ "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, "license": "MIT" }, "node_modules/nanoid": { @@ -5676,82 +5550,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/openapi-fetch": { - "version": "0.14.0", - "resolved": "https://registry.npmjs.org/openapi-fetch/-/openapi-fetch-0.14.0.tgz", - "integrity": "sha512-PshIdm1NgdLvb05zp8LqRQMNSKzIlPkyMxYFxwyHR+UlKD4t2nUjkDhNxeRbhRSEd3x5EUNh2w5sJYwkhOH4fg==", - "license": "MIT", - "dependencies": { - "openapi-typescript-helpers": "^0.0.15" - } - }, - "node_modules/openapi-typescript": { - "version": "7.6.1", - "resolved": "https://registry.npmjs.org/openapi-typescript/-/openapi-typescript-7.6.1.tgz", - "integrity": "sha512-F7RXEeo/heF3O9lOXo2bNjCOtfp7u+D6W3a3VNEH2xE6v+fxLtn5nq0uvUcA1F5aT+CMhNeC5Uqtg5tlXFX/ag==", - "license": "MIT", - "dependencies": { - "@redocly/openapi-core": "^1.28.0", - "ansi-colors": "^4.1.3", - "change-case": "^5.4.4", - "parse-json": "^8.1.0", - "supports-color": "^9.4.0", - "yargs-parser": "^21.1.1" - }, - "bin": { - "openapi-typescript": "bin/cli.js" - }, - "peerDependencies": { - "typescript": "^5.x" - } - }, - "node_modules/openapi-typescript-helpers": { - "version": "0.0.15", - "resolved": "https://registry.npmjs.org/openapi-typescript-helpers/-/openapi-typescript-helpers-0.0.15.tgz", - "integrity": "sha512-opyTPaunsklCBpTK8JGef6mfPhLSnyy5a0IN9vKtx3+4aExf+KxEqYwIy3hqkedXIB97u357uLMJsOnm3GVjsw==", - "license": "MIT" - }, - "node_modules/openapi-typescript/node_modules/parse-json": { - "version": "8.2.0", - "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-8.2.0.tgz", - "integrity": "sha512-eONBZy4hm2AgxjNFd8a4nyDJnzUAH0g34xSQAwWEVGCjdZ4ZL7dKZBfq267GWP/JaS9zW62Xs2FeAdDvpHHJGQ==", - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.26.2", - "index-to-position": "^1.0.0", - "type-fest": "^4.37.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/openapi-typescript/node_modules/supports-color": { - "version": "9.4.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-9.4.0.tgz", - "integrity": "sha512-VL+lNrEoIXww1coLPOmiEmK/0sGigko5COxI09KzHc2VJXJsQ37UaQ+8quuxjDeA7+KnLGTWRyOXSLLR2Wb4jw==", - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/supports-color?sponsor=1" - } - }, - "node_modules/openapi-typescript/node_modules/type-fest": { - "version": "4.38.0", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.38.0.tgz", - "integrity": "sha512-2dBz5D5ycHIoliLYLi0Q2V7KRaDlH0uWIvmk7TYlAg5slqwiPv1ezJdZm1QEM0xgk29oYWMCbIG7E6gHpvChlg==", - "license": "(MIT OR CC0-1.0)", - "engines": { - "node": ">=16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -5913,6 +5711,7 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, "license": "ISC" }, "node_modules/picomatch": { @@ -6007,15 +5806,6 @@ "node": ">=8" } }, - "node_modules/pluralize": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/pluralize/-/pluralize-8.0.0.tgz", - "integrity": "sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==", - "license": "MIT", - "engines": { - "node": ">=4" - } - }, "node_modules/postcss": { "version": "8.5.3", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.3.tgz", @@ -6333,6 +6123,7 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -7276,6 +7067,7 @@ "version": "5.8.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz", "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", + "dev": true, "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", @@ -7366,12 +7158,6 @@ "punycode": "^2.1.0" } }, - "node_modules/uri-js-replace": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/uri-js-replace/-/uri-js-replace-1.0.1.tgz", - "integrity": "sha512-W+C9NWNLFOoBI2QWDp4UT9pv65r2w5Cx+3sTYFvtMdDBxkKt1syCqsUdSFAChbEe1uK5TfS04wt/nGwmaeIQ0g==", - "license": "MIT" - }, "node_modules/url": { "version": "0.11.4", "resolved": "https://registry.npmjs.org/url/-/url-0.11.4.tgz", @@ -7814,12 +7600,6 @@ "dev": true, "license": "ISC" }, - "node_modules/yaml-ast-parser": { - "version": "0.0.43", - "resolved": "https://registry.npmjs.org/yaml-ast-parser/-/yaml-ast-parser-0.0.43.tgz", - "integrity": "sha512-2PTINUwsRqSd+s8XxKaJWQlUuEMHJQyEuh2edBbW8KNJz0SJPwUSD2zRWqezFEdN7IzAgeuYHFUCF7o8zRdZ0A==", - "license": "Apache-2.0" - }, "node_modules/yargs": { "version": "17.7.2", "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", @@ -7843,6 +7623,7 @@ "version": "21.1.1", "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "dev": true, "license": "ISC", "engines": { "node": ">=12" @@ -7895,8 +7676,6 @@ "dependencies": { "byte-base64": "^1.1.0", "minimatch": "^10.0.1", - "openapi-fetch": "0.14.0", - "openapi-typescript": "7.6.1", "p-queue": "^8.1.0", "uuid": "^11.1.0" }, diff --git a/frontend/sync-client/package.json b/frontend/sync-client/package.json index 655dd8a4..ab01b233 100644 --- a/frontend/sync-client/package.json +++ b/frontend/sync-client/package.json @@ -15,8 +15,6 @@ "dependencies": { "byte-base64": "^1.1.0", "minimatch": "^10.0.1", - "openapi-fetch": "0.14.0", - "openapi-typescript": "7.6.1", "p-queue": "^8.1.0", "uuid": "^11.1.0" }, @@ -34,4 +32,4 @@ "webpack-merge": "^6.0.1", "ws": "^8.18.1" } -} +} \ No newline at end of file diff --git a/frontend/sync-client/src/services/connection-status.ts b/frontend/sync-client/src/services/connection-status.ts index 572d8895..3934639f 100644 --- a/frontend/sync-client/src/services/connection-status.ts +++ b/frontend/sync-client/src/services/connection-status.ts @@ -51,7 +51,10 @@ export class ConnectionStatus { logger: Logger, fetch: typeof globalThis.fetch = globalThis.fetch ): typeof globalThis.fetch { - return async (input: RequestInfo | URL): Promise => { + return async ( + input: RequestInfo | URL, + init?: RequestInit + ): Promise => { while (!this.canFetch) { await this.until; } @@ -63,7 +66,7 @@ export class ConnectionStatus { ? input.clone() : input; - const fetchPromise = fetch(_input); + const fetchPromise = fetch(_input, init); // We only want to catch rejections from `this.until` let result: symbol | Response | undefined = undefined; diff --git a/frontend/sync-client/src/services/sync-service.ts b/frontend/sync-client/src/services/sync-service.ts index bbb559ff..3d1cfadb 100644 --- a/frontend/sync-client/src/services/sync-service.ts +++ b/frontend/sync-client/src/services/sync-service.ts @@ -1,16 +1,21 @@ -import type { Client } from "openapi-fetch"; -import createClient from "openapi-fetch"; -import type { components, paths } from "./types/http-api"; // generated by openapi-typescript import type { DocumentId, RelativePath, VaultUpdateId } from "../persistence/database"; + import type { Logger } from "../tracing/logger"; import type { Settings } from "../persistence/settings"; import type { ConnectionStatus } from "./connection-status"; import { sleep } from "../utils/sleep"; import { SyncResetError } from "./sync-reset-error"; +import { SerializedError } from "./types/SerializedError"; +import { DocumentVersionWithoutContent } from "./types/DocumentVersionWithoutContent"; +import { DocumentUpdateResponse } from "./types/DocumentUpdateResponse"; +import { DocumentVersion } from "./types/DocumentVersion"; +import { FetchLatestDocumentsResponse } from "./types/FetchLatestDocumentsResponse"; +import { PingResponse } from "./types/PingResponse"; +import { DeleteDocumentVersion } from "./types/DeleteDocumentVersion"; export interface CheckConnectionResult { isSuccessful: boolean; @@ -19,34 +24,34 @@ export interface CheckConnectionResult { export class SyncService { private static readonly NETWORK_RETRY_INTERVAL_MS = 1000; - private client: Client; - private pingClient: Client; + private client: typeof globalThis.fetch; + private pingClient: typeof globalThis.fetch; public constructor( private readonly deviceId: string, private readonly connectionStatus: ConnectionStatus, private readonly settings: Settings, private readonly logger: Logger, - private readonly fetchImplementation: typeof globalThis.fetch = globalThis.fetch + fetchImplementation: typeof globalThis.fetch = globalThis.fetch ) { - [this.client, this.pingClient] = this.createClient( - this.settings.getSettings().remoteUri + // ensure that if it's called a method, `this` won't be bound to the instance + const unboundFetch: typeof globalThis.fetch = (...args) => + fetchImplementation(...args); + + this.client = this.connectionStatus.getFetchImplementation( + this.logger, + unboundFetch ); - - settings.addOnSettingsChangeListener((newSettings, oldSettings) => { - if (newSettings.remoteUri === oldSettings.remoteUri) { - return; - } - - [this.client, this.pingClient] = this.createClient( - newSettings.remoteUri - ); - }); + this.pingClient = unboundFetch; } - private static formatError( - error: components["schemas"]["SerializedError"] - ): string { + private getUrl(path: string): string { + let { vaultName, remoteUri } = this.settings.getSettings(); + remoteUri = remoteUri.replace(/\/+$/, ""); + return `${remoteUri}/vaults/${vaultName}${path}`; + } + + private static formatError(error: SerializedError): string { let result = error.message; if (error.causes.length > 0) { const causes = error.causes.join(", "); @@ -64,9 +69,7 @@ export class SyncService { documentId?: DocumentId; relativePath: RelativePath; contentBytes: Uint8Array; - }): Promise { - const { vaultName } = this.settings.getSettings(); - + }): Promise { return this.withRetries(async () => { const formData = new FormData(); if (documentId !== undefined) { @@ -75,35 +78,28 @@ export class SyncService { formData.append("relative_path", relativePath); formData.append("content", new Blob([contentBytes])); - const response = await this.client.POST( - "/vaults/{vault_id}/documents", - { - params: { - path: { - vault_id: vaultName - }, - header: { - "device-id": this.deviceId - } - }, - // eslint-disable-next-line - body: formData as any // FormData is not supported by openapi-fetch - } - ); + const response = await this.client(this.getUrl("/documents"), { + method: "POST", + body: formData, + headers: this.getDefaultHeaders() + }); - if (!response.data) { + const result: SerializedError | DocumentVersionWithoutContent = + await response.json(); + + if ("errorType" in result) { throw new Error( - `Failed to create document: ${SyncService.formatError(response.error)}` + `Failed to create document: ${SyncService.formatError(result)}` ); } this.logger.debug( - `Created document ${JSON.stringify(response.data)} with id ${ - response.data.documentId + `Created document ${JSON.stringify(result)} with id ${ + result.documentId }` ); - return response.data; + return result; }); } @@ -117,9 +113,7 @@ export class SyncService { documentId: DocumentId; relativePath: RelativePath; contentBytes: Uint8Array; - }): Promise { - const { vaultName } = this.settings.getSettings(); - + }): Promise { return this.withRetries(async () => { this.logger.debug( `Updating document ${documentId} with parent version ${parentVersionId} and relative path ${relativePath}` @@ -129,36 +123,31 @@ export class SyncService { formData.append("relative_path", relativePath); formData.append("content", new Blob([contentBytes])); - const response = await this.client.PUT( - "/vaults/{vault_id}/documents/{document_id}", + const response = await this.client( + this.getUrl(`/documents/${documentId}`), { - params: { - path: { - vault_id: vaultName, - document_id: documentId - }, - header: { - "device-id": this.deviceId - } - }, - // eslint-disable-next-line - body: formData as any // FormData is not supported by openapi-fetch + method: "PUT", + body: formData, + headers: this.getDefaultHeaders() } ); - if (!response.data) { + const result: SerializedError | DocumentUpdateResponse = + await response.json(); + + if ("errorType" in result) { throw new Error( - `Failed to update document: ${SyncService.formatError(response.error)}` + `Failed to update document: ${SyncService.formatError(result)}` ); } this.logger.debug( - `Updated document ${JSON.stringify(response.data)} with id ${ - response.data.documentId - }` + `Updated document ${JSON.stringify(result)} with id ${ + result.documentId + }}` ); - return response.data; + return result; }); } @@ -168,38 +157,37 @@ export class SyncService { }: { documentId: DocumentId; relativePath: RelativePath; - }): Promise { + }): Promise { return this.withRetries(async () => { - const { vaultName } = this.settings.getSettings(); - - const response = await this.client.DELETE( - "/vaults/{vault_id}/documents/{document_id}", + const request: DeleteDocumentVersion = { + relativePath + }; + const response = await this.client( + this.getUrl(`/documents/${documentId}`), { - params: { - path: { - vault_id: vaultName, - document_id: documentId - }, - header: { - "device-id": this.deviceId - } - }, - - body: { - relativePath + method: "DELETE", + body: JSON.stringify(request), + headers: { + "Content-Type": "application/json", + ...this.getDefaultHeaders() } } ); - if (response.error) { - throw new Error(`Failed to delete document`); + const result: SerializedError | DocumentVersionWithoutContent = + await response.json(); + + if ("errorType" in result) { + throw new Error( + `Failed to delete document: ${SyncService.formatError(result)}` + ); } this.logger.debug( `Deleted document ${relativePath} with id ${documentId}` ); - return response.data; + return result; }); } @@ -207,100 +195,75 @@ export class SyncService { documentId }: { documentId: DocumentId; - }): Promise { - const { vaultName } = this.settings.getSettings(); - + }): Promise { return this.withRetries(async () => { - const response = await this.client.GET( - "/vaults/{vault_id}/documents/{document_id}", + const response = await this.client( + this.getUrl(`/documents/${documentId}`), { - params: { - path: { - vault_id: vaultName, - document_id: documentId - } - } + headers: this.getDefaultHeaders() } ); - if (!response.data) { + const result: SerializedError | DocumentVersion = + await response.json(); + + if ("errorType" in result) { throw new Error( - `Failed to get document: ${SyncService.formatError(response.error)}` + `Failed to get document: ${SyncService.formatError(result)}` ); } this.logger.debug( - `Get document ${response.data.relativePath} with id ${response.data.documentId}` + `Get document ${result.relativePath} with id ${result.documentId}` ); - return response.data; + return result; }); } public async getAll( since?: VaultUpdateId - ): Promise { + ): Promise { return this.withRetries(async () => { - const { vaultName } = this.settings.getSettings(); + const url = new URL(this.getUrl("/documents")); + if (since !== undefined) { + url.searchParams.append("since", since.toString()); + } + const response = await this.client(url.toString(), { + headers: this.getDefaultHeaders() + }); - const response = await this.client.GET( - "/vaults/{vault_id}/documents", - { - params: { - path: { - vault_id: vaultName - }, - query: { - since_update_id: since - } - } - } - ); + const result: SerializedError | FetchLatestDocumentsResponse = + await response.json(); - const { error } = response; - if (error) { + if ("errorType" in result) { throw new Error( - `Failed to get documents: ${SyncService.formatError(response.error)}` + `Failed to get documents: ${SyncService.formatError(result)}` ); } this.logger.debug( - `Got ${response.data.latestDocuments.length} document metadata` + `Got ${result.latestDocuments.length} document metadata` ); - return response.data; + return result; }); } public async checkConnection(): Promise { - const { vaultName } = this.settings.getSettings(); - try { - const response = await this.pingClient.GET( - "/vaults/{vault_id}/ping", - { - params: { - header: { - authorization: `Bearer ${this.settings.getSettings().token}` - }, - path: { - vault_id: vaultName - } - } - } - ); + const response = await this.pingClient(this.getUrl("/ping"), { + headers: this.getDefaultHeaders() + }); + const result: PingResponse | SerializedError = + await response.json(); - this.logger.debug( - `Ping response: ${JSON.stringify(response.data)}` - ); - - if (!response.data) { + if ("errorType" in result) { throw new Error( - `Failed to ping server: ${SyncService.formatError(response.error)}` + `Failed to ping server: ${SyncService.formatError(result)}` ); } - const result = response.data; if (result.isAuthenticated) { return { isSuccessful: true, @@ -320,29 +283,11 @@ export class SyncService { } } - /** - * Create a client and a ping client for the given remote URI. - */ - private createClient(remoteUri: string): [Client, Client] { - return [ - createClient({ - baseUrl: remoteUri, - fetch: this.connectionStatus.getFetchImplementation( - this.logger, - this.fetchImplementation - ), - headers: { - authorization: `Bearer ${this.settings.getSettings().token}` - } - }), - createClient({ - baseUrl: remoteUri, - fetch: this.fetchImplementation, - headers: { - authorization: `Bearer ${this.settings.getSettings().token}` - } - }) - ]; + private getDefaultHeaders(): Record { + return { + "device-id": this.deviceId, + authorization: `Bearer ${this.settings.getSettings().token}` + }; } private async withRetries(fn: () => Promise): Promise { diff --git a/frontend/sync-client/src/services/types/DocumentVersion.ts b/frontend/sync-client/src/services/types/DocumentVersion.ts index 0bdad5c9..37bd32ca 100644 --- a/frontend/sync-client/src/services/types/DocumentVersion.ts +++ b/frontend/sync-client/src/services/types/DocumentVersion.ts @@ -1,3 +1,3 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -export type DocumentVersion = { vaultUpdateId: bigint, documentId: string, relativePath: string, updatedDate: string, contentBase64: string, isDeleted: boolean, userId: string, deviceId: string, }; +export type DocumentVersion = { vaultUpdateId: number, documentId: string, relativePath: string, updatedDate: string, contentBase64: string, isDeleted: boolean, userId: string, deviceId: string, }; diff --git a/frontend/sync-client/src/services/types/SerializedError.ts b/frontend/sync-client/src/services/types/SerializedError.ts new file mode 100644 index 00000000..5e3fa9b9 --- /dev/null +++ b/frontend/sync-client/src/services/types/SerializedError.ts @@ -0,0 +1,3 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type SerializedError = { errorType: string, message: string, causes: Array, }; diff --git a/frontend/sync-client/src/services/types/http-api.ts b/frontend/sync-client/src/services/types/http-api.ts deleted file mode 100644 index 5f2c08f5..00000000 --- a/frontend/sync-client/src/services/types/http-api.ts +++ /dev/null @@ -1,648 +0,0 @@ -/** - * This file was auto-generated by openapi-typescript. - * Do not make direct changes to the file. - */ - -export interface paths { - "/vaults/{vault_id}/documents": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - get: { - parameters: { - query?: { - since_update_id?: number | null; - }; - header?: never; - path: { - vault_id: string; - }; - cookie?: never; - }; - requestBody?: never; - responses: { - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["FetchLatestDocumentsResponse"]; - }; - }; - default: { - headers: { - [name: string]: unknown; - }; - content: { - /** @example { - * "causes": [], - * "message": "An error has occurred" - * } */ - "application/json": components["schemas"]["SerializedError"]; - }; - }; - }; - }; - put?: never; - post: { - parameters: { - query?: never; - header: { - "device-id": string; - }; - path: { - vault_id: string; - }; - cookie?: never; - }; - requestBody: { - content: { - "multipart/form-data": components["schemas"]["CreateDocumentVersionMultipart"]; - }; - }; - responses: { - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["DocumentVersionWithoutContent"]; - }; - }; - default: { - headers: { - [name: string]: unknown; - }; - content: { - /** @example { - * "causes": [], - * "message": "An error has occurred" - * } */ - "application/json": components["schemas"]["SerializedError"]; - }; - }; - }; - }; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - "/vaults/{vault_id}/documents/json": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - get?: never; - put?: never; - post: { - parameters: { - query?: never; - header: { - "device-id": string; - }; - path: { - vault_id: string; - }; - cookie?: never; - }; - requestBody: { - content: { - "application/json": components["schemas"]["CreateDocumentVersion"]; - }; - }; - responses: { - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["DocumentVersionWithoutContent"]; - }; - }; - default: { - headers: { - [name: string]: unknown; - }; - content: { - /** @example { - * "causes": [], - * "message": "An error has occurred" - * } */ - "application/json": components["schemas"]["SerializedError"]; - }; - }; - }; - }; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - "/vaults/{vault_id}/documents/{document_id}": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - get: { - parameters: { - query?: never; - header?: never; - path: { - document_id: string; - vault_id: string; - }; - cookie?: never; - }; - requestBody?: never; - responses: { - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["DocumentVersion"]; - }; - }; - default: { - headers: { - [name: string]: unknown; - }; - content: { - /** @example { - * "causes": [], - * "message": "An error has occurred" - * } */ - "application/json": components["schemas"]["SerializedError"]; - }; - }; - }; - }; - put: { - parameters: { - query?: never; - header: { - "device-id": string; - }; - path: { - document_id: string; - vault_id: string; - }; - cookie?: never; - }; - requestBody: { - content: { - "multipart/form-data": components["schemas"]["UpdateDocumentVersionMultipart"]; - }; - }; - responses: { - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["DocumentUpdateResponse"]; - }; - }; - default: { - headers: { - [name: string]: unknown; - }; - content: { - /** @example { - * "causes": [], - * "message": "An error has occurred" - * } */ - "application/json": components["schemas"]["SerializedError"]; - }; - }; - }; - }; - post?: never; - delete: { - parameters: { - query?: never; - header: { - "device-id": string; - }; - path: { - document_id: string; - vault_id: string; - }; - cookie?: never; - }; - requestBody: { - content: { - "application/json": components["schemas"]["DeleteDocumentVersion"]; - }; - }; - responses: { - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["DocumentVersionWithoutContent"]; - }; - }; - default: { - headers: { - [name: string]: unknown; - }; - content: { - /** @example { - * "causes": [], - * "message": "An error has occurred" - * } */ - "application/json": components["schemas"]["SerializedError"]; - }; - }; - }; - }; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - "/vaults/{vault_id}/documents/{document_id}/json": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - get?: never; - put: { - parameters: { - query?: never; - header: { - "device-id": string; - }; - path: { - document_id: string; - vault_id: string; - }; - cookie?: never; - }; - requestBody: { - content: { - "application/json": components["schemas"]["UpdateDocumentVersion"]; - }; - }; - responses: { - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["DocumentUpdateResponse"]; - }; - }; - default: { - headers: { - [name: string]: unknown; - }; - content: { - /** @example { - * "causes": [], - * "message": "An error has occurred" - * } */ - "application/json": components["schemas"]["SerializedError"]; - }; - }; - }; - }; - post?: never; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - "/vaults/{vault_id}/documents/{document_id}/versions/{version_id}": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - get?: never; - put: { - parameters: { - query?: never; - header?: never; - path: { - document_id: string; - vault_id: string; - vault_update_id: number; - }; - cookie?: never; - }; - requestBody?: never; - responses: { - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["DocumentVersion"]; - }; - }; - default: { - headers: { - [name: string]: unknown; - }; - content: { - /** @example { - * "causes": [], - * "message": "An error has occurred" - * } */ - "application/json": components["schemas"]["SerializedError"]; - }; - }; - }; - }; - post?: never; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - "/vaults/{vault_id}/documents/{document_id}/versions/{version_id}/content": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - get?: never; - put: { - parameters: { - query?: never; - header?: never; - path: { - document_id: string; - vault_id: string; - vault_update_id: number; - }; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description byte stream */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "application/octet-stream": unknown; - }; - }; - default: { - headers: { - [name: string]: unknown; - }; - content: { - /** @example { - * "causes": [], - * "message": "An error has occurred" - * } */ - "application/json": components["schemas"]["SerializedError"]; - }; - }; - }; - }; - post?: never; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - "/vaults/{vault_id}/ping": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - get: { - parameters: { - query?: never; - header?: { - authorization?: string; - }; - path: { - vault_id: string; - }; - cookie?: never; - }; - requestBody?: never; - responses: { - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["PingResponse"]; - }; - }; - default: { - headers: { - [name: string]: unknown; - }; - content: { - /** @example { - * "causes": [], - * "message": "An error has occurred" - * } */ - "application/json": components["schemas"]["SerializedError"]; - }; - }; - }; - }; - put?: never; - post?: never; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; -} -export type webhooks = Record; -export interface components { - schemas: { - Array_of_uint8: number[]; - CreateDocumentPathParams: { - vault_id: string; - }; - CreateDocumentVersion: { - contentBase64: string; - /** - * Format: uuid - * @description The client can decide the document id (if it wishes to) in order to help with syncing. If the client does not provide a document id, the server will generate one. If the client provides a document id it must not already exist in the database. - */ - documentId?: string | null; - relativePath: string; - }; - CreateDocumentVersionMultipart: { - content: components["schemas"]["Array_of_uint8"]; - /** Format: uuid */ - document_id?: string | null; - relative_path: string; - }; - DeleteDocumentPathParams: { - /** Format: uuid */ - document_id: string; - vault_id: string; - }; - DeleteDocumentVersion: { - relativePath: string; - }; - /** @description Response to an update document request. */ - DocumentUpdateResponse: { - /** Format: uint64 */ - contentSize: number; - deviceId: string; - /** Format: uuid */ - documentId: string; - isDeleted: boolean; - relativePath: string; - /** @enum {string} */ - type: "FastForwardUpdate"; - /** Format: date-time */ - updatedDate: string; - userId: string; - /** Format: int64 */ - vaultUpdateId: number; - } | { - contentBase64: string; - deviceId: string; - /** Format: uuid */ - documentId: string; - isDeleted: boolean; - relativePath: string; - /** @enum {string} */ - type: "MergingUpdate"; - /** Format: date-time */ - updatedDate: string; - userId: string; - /** Format: int64 */ - vaultUpdateId: number; - }; - DocumentVersion: { - contentBase64: string; - deviceId: string; - /** Format: uuid */ - documentId: string; - isDeleted: boolean; - relativePath: string; - /** Format: date-time */ - updatedDate: string; - userId: string; - /** Format: int64 */ - vaultUpdateId: number; - }; - DocumentVersionWithoutContent: { - /** Format: uint64 */ - contentSize: number; - deviceId: string; - /** Format: uuid */ - documentId: string; - isDeleted: boolean; - relativePath: string; - /** Format: date-time */ - updatedDate: string; - userId: string; - /** Format: int64 */ - vaultUpdateId: number; - }; - FetchDocumentVersionContentPathParams: { - /** Format: uuid */ - document_id: string; - vault_id: string; - /** Format: int64 */ - vault_update_id: number; - }; - FetchDocumentVersionPathParams: { - /** Format: uuid */ - document_id: string; - vault_id: string; - /** Format: int64 */ - vault_update_id: number; - }; - FetchLatestDocumentVersionPathParams: { - /** Format: uuid */ - document_id: string; - vault_id: string; - }; - FetchLatestDocumentsPathParams: { - vault_id: string; - }; - /** @description Response to a fetch latest documents request. */ - FetchLatestDocumentsResponse: { - /** - * Format: int64 - * @description The update ID of the latest document in the response. - */ - lastUpdateId: number; - latestDocuments: components["schemas"]["DocumentVersionWithoutContent"][]; - }; - PingPathParams: { - vault_id: string; - }; - /** @description Response to a ping request. */ - PingResponse: { - /** @description Whether the client is authenticated based on the sent Authorization header. */ - isAuthenticated: boolean; - /** @description Semantic version of the server. */ - serverVersion: string; - }; - QueryParams: { - /** Format: int64 */ - since_update_id?: number | null; - }; - SerializedError: { - causes: string[]; - message: string; - }; - UpdateDocumentPathParams: { - /** Format: uuid */ - document_id: string; - vault_id: string; - }; - UpdateDocumentVersion: { - contentBase64: string; - /** Format: int64 */ - parentVersionId: number; - relativePath: string; - }; - UpdateDocumentVersionMultipart: { - content: components["schemas"]["Array_of_uint8"]; - /** Format: int64 */ - parentVersionId: number; - relativePath: string; - }; - WebSocketPathParams: { - vault_id: string; - }; - }; - responses: never; - parameters: never; - requestBodies: never; - headers: never; - pathItems: never; -} -export type $defs = Record; -export type operations = Record; diff --git a/frontend/sync-client/src/sync-operations/syncer.ts b/frontend/sync-client/src/sync-operations/syncer.ts index 57d99f14..9270a8ed 100644 --- a/frontend/sync-client/src/sync-operations/syncer.ts +++ b/frontend/sync-client/src/sync-operations/syncer.ts @@ -9,7 +9,6 @@ import type { Logger } from "../tracing/logger"; import PQueue from "p-queue"; import { hash } from "../utils/hash"; import { v4 as uuidv4 } from "uuid"; -import type { components } from "../services/types/http-api"; import type { Settings, SyncSettings } from "../persistence/settings"; import type { FileOperations } from "../file-operations/file-operations"; import { findMatchingFile } from "../utils/find-matching-file"; @@ -17,6 +16,7 @@ import type { UnrestrictedSyncer } from "./unrestricted-syncer"; import { createPromise } from "../utils/create-promise"; import { SyncResetError } from "../services/sync-reset-error"; import { Locks } from "../utils/locks"; +import { DocumentVersionWithoutContent } from "../services/types/DocumentVersionWithoutContent"; export class Syncer { private readonly remoteDocumentsLock: Locks; @@ -255,7 +255,7 @@ export class Syncer { } public async syncRemotelyUpdatedFile( - remoteVersion: components["schemas"]["DocumentVersionWithoutContent"] + remoteVersion: DocumentVersionWithoutContent ): Promise { let document = this.database.getDocumentByDocumentId( remoteVersion.documentId diff --git a/frontend/sync-client/src/sync-operations/unrestricted-syncer.ts b/frontend/sync-client/src/sync-operations/unrestricted-syncer.ts index 76470120..f892e640 100644 --- a/frontend/sync-client/src/sync-operations/unrestricted-syncer.ts +++ b/frontend/sync-client/src/sync-operations/unrestricted-syncer.ts @@ -17,7 +17,6 @@ import type { } from "../tracing/sync-history"; import { SyncStatus, SyncType } from "../tracing/sync-history"; import { EMPTY_HASH, hash } from "../utils/hash"; -import type { components } from "../services/types/http-api"; import { deserialize } from "../utils/deserialize"; import type { Settings } from "../persistence/settings"; import type { FileOperations } from "../file-operations/file-operations"; @@ -25,6 +24,9 @@ import { createPromise } from "../utils/create-promise"; import { FileNotFoundError } from "../file-operations/file-not-found-error"; import { SyncResetError } from "../services/sync-reset-error"; import { globsToRegexes } from "../utils/globs-to-regexes"; +import { DocumentVersion } from "../services/types/DocumentVersion"; +import { DocumentUpdateResponse } from "../services/types/DocumentUpdateResponse"; +import { DocumentVersionWithoutContent } from "../services/types/DocumentVersionWithoutContent"; export class UnrestrictedSyncer { private ignorePatterns: RegExp[]; @@ -172,10 +174,8 @@ export class UnrestrictedSyncer { document.metadata.hash === contentHash && oldPath === undefined ); - let response: - | components["schemas"]["DocumentVersion"] - | components["schemas"]["DocumentUpdateResponse"] - | undefined = undefined; + let response: DocumentVersion | DocumentUpdateResponse | undefined = + undefined; if (areThereLocalChanges) { response = await this.syncService.put({ @@ -332,7 +332,7 @@ export class UnrestrictedSyncer { } public async unrestrictedSyncRemotelyUpdatedFile( - remoteVersion: components["schemas"]["DocumentVersionWithoutContent"], + remoteVersion: DocumentVersionWithoutContent, document?: DocumentRecord ): Promise { const updateDetails: SyncCreateDetails = { -- 2.47.2 From bfb1cd579cb72941e02c3a40c48d7af392978460 Mon Sep 17 00:00:00 2001 From: Andras Schmelczer Date: Sat, 7 Jun 2025 21:34:56 +0100 Subject: [PATCH 19/29] Bump frontend deps --- frontend/obsidian-plugin/package.json | 8 +- frontend/package-lock.json | 247 +++++++++++++++----------- frontend/package.json | 6 +- frontend/sync-client/package.json | 6 +- frontend/test-client/package.json | 4 +- 5 files changed, 158 insertions(+), 113 deletions(-) diff --git a/frontend/obsidian-plugin/package.json b/frontend/obsidian-plugin/package.json index cf72934b..2c3f6d7c 100644 --- a/frontend/obsidian-plugin/package.json +++ b/frontend/obsidian-plugin/package.json @@ -14,7 +14,7 @@ "license": "MIT", "devDependencies": { "@types/jest": "^29.5.14", - "@types/node": "^22.14.0", + "@types/node": "^22.15.30", "css-loader": "^7.1.2", "date-fns": "^4.1.0", "file-loader": "^6.2.0", @@ -23,7 +23,7 @@ "mini-css-extract-plugin": "^2.9.2", "obsidian": "1.8.7", "resolve-url-loader": "^5.0.0", - "sass": "^1.89.0", + "sass": "^1.89.1", "sass-loader": "^16.0.5", "sync-client": "file:../sync-client", "terser-webpack-plugin": "^5.3.14", @@ -33,7 +33,7 @@ "typescript": "5.8.3", "url": "^0.11.4", "virtual-scroller": "^1.13.1", - "webpack": "^5.98.0", + "webpack": "^5.99.9", "webpack-cli": "^6.0.1" } -} +} \ No newline at end of file diff --git a/frontend/package-lock.json b/frontend/package-lock.json index a2220c54..a5343d78 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -12,11 +12,11 @@ ], "devDependencies": { "concurrently": "^9.1.2", - "eslint": "9.23.0", + "eslint": "9.28.0", "eslint-plugin-unused-imports": "^4.1.4", - "npm-check-updates": "^17.1.16", + "npm-check-updates": "^18.0.1", "prettier": "^3.5.3", - "typescript-eslint": "8.32.1" + "typescript-eslint": "8.33.1" } }, "../backend/sync_lib/pkg": { @@ -632,9 +632,9 @@ } }, "node_modules/@eslint/config-array": { - "version": "0.19.2", - "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.19.2.tgz", - "integrity": "sha512-GNKqxfHG2ySmJOBSHg7LxeUx4xpuCoFjacmlCoYWEbaPXLwvfIjixRI12xCQZeULksQb23uiA8F40w5TojpV7w==", + "version": "0.20.0", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.20.0.tgz", + "integrity": "sha512-fxlS1kkIjx8+vy2SjuCB94q3htSNrufYTXubwiBFeaQHbH6Ipi43gFJq2zCMt6PHhImH3Xmr0NksKDvchWlpQQ==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -657,9 +657,9 @@ } }, "node_modules/@eslint/core": { - "version": "0.12.0", - "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.12.0.tgz", - "integrity": "sha512-cmrR6pytBuSMTaBweKoGMwu3EiHiEC+DoyupPmlZ0HxBJBtIxwe+j/E4XPIKNx+Q74c8lXKPwYawBf5glsTkHg==", + "version": "0.14.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.14.0.tgz", + "integrity": "sha512-qIbV0/JZr7iSDjqAc60IqbLdsj9GDt16xQtWD+B78d/HAlvysGdZZ6rpJHGAc2T0FQx1X6thsSPdnoiGKdNtdg==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -694,13 +694,16 @@ } }, "node_modules/@eslint/js": { - "version": "9.23.0", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.23.0.tgz", - "integrity": "sha512-35MJ8vCPU0ZMxo7zfev2pypqTwWTofFZO6m4KAtdoFhRpLJUpHTZZ+KB3C7Hb1d7bULYwO4lJXGCi5Se+8OMbw==", + "version": "9.28.0", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.28.0.tgz", + "integrity": "sha512-fnqSjGWd/CoIp4EXIxWVK/sHA6DOHN4+8Ix2cX5ycOY7LG0UY8nHCU5pIp2eaE1Mc7Qd8kHspYNzYXT2ojPLzg==", "dev": true, "license": "MIT", "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" } }, "node_modules/@eslint/object-schema": { @@ -714,13 +717,13 @@ } }, "node_modules/@eslint/plugin-kit": { - "version": "0.2.7", - "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.2.7.tgz", - "integrity": "sha512-JubJ5B2pJ4k4yGxaNLdbjrnk9d/iDz6/q8wOilpIowd6PJPgaxCuHBnBszq7Ce2TyMrywm5r4PnKm6V3iiZF+g==", + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.3.1.tgz", + "integrity": "sha512-0J+zgWxHN+xXONWIyPWKFMgVuJoZuGiIFu8yxk7RJjxkzpGmyja5wRFqZIVtjDVOQpV+Rw0iOAjYPE2eQyjr0w==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@eslint/core": "^0.12.0", + "@eslint/core": "^0.14.0", "levn": "^0.4.1" }, "engines": { @@ -1789,9 +1792,9 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "22.14.0", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.14.0.tgz", - "integrity": "sha512-Kmpl+z84ILoG+3T/zQFyAJsU6EPTmOCj8/2+83fSN6djd6I4o7uOuGIH6vq3PrjY5BGitSbFuMN18j3iknubbA==", + "version": "22.15.30", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.15.30.tgz", + "integrity": "sha512-6Q7lr06bEHdlfplU6YRbgG1SFBdlsfNC4/lX+SkhiTs0cpJkOElmWls8PxDFv4yY/xKb8Y6SO0OmSX4wgqTZbA==", "dev": true, "license": "MIT", "dependencies": { @@ -1833,17 +1836,17 @@ "license": "MIT" }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.32.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.32.1.tgz", - "integrity": "sha512-6u6Plg9nP/J1GRpe/vcjjabo6Uc5YQPAMxsgQyGC/I0RuukiG1wIe3+Vtg3IrSCVJDmqK3j8adrtzXSENRtFgg==", + "version": "8.33.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.33.1.tgz", + "integrity": "sha512-TDCXj+YxLgtvxvFlAvpoRv9MAncDLBV2oT9Bd7YBGC/b/sEURoOYuIwLI99rjWOfY3QtDzO+mk0n4AmdFExW8A==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/regexpp": "^4.10.0", - "@typescript-eslint/scope-manager": "8.32.1", - "@typescript-eslint/type-utils": "8.32.1", - "@typescript-eslint/utils": "8.32.1", - "@typescript-eslint/visitor-keys": "8.32.1", + "@typescript-eslint/scope-manager": "8.33.1", + "@typescript-eslint/type-utils": "8.33.1", + "@typescript-eslint/utils": "8.33.1", + "@typescript-eslint/visitor-keys": "8.33.1", "graphemer": "^1.4.0", "ignore": "^7.0.0", "natural-compare": "^1.4.0", @@ -1857,7 +1860,7 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "@typescript-eslint/parser": "^8.0.0 || ^8.0.0-alpha.0", + "@typescript-eslint/parser": "^8.33.1", "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <5.9.0" } @@ -1873,16 +1876,16 @@ } }, "node_modules/@typescript-eslint/parser": { - "version": "8.32.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.32.1.tgz", - "integrity": "sha512-LKMrmwCPoLhM45Z00O1ulb6jwyVr2kr3XJp+G+tSEZcbauNnScewcQwtJqXDhXeYPDEjZ8C1SjXm015CirEmGg==", + "version": "8.33.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.33.1.tgz", + "integrity": "sha512-qwxv6dq682yVvgKKp2qWwLgRbscDAYktPptK4JPojCwwi3R9cwrvIxS4lvBpzmcqzR4bdn54Z0IG1uHFskW4dA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/scope-manager": "8.32.1", - "@typescript-eslint/types": "8.32.1", - "@typescript-eslint/typescript-estree": "8.32.1", - "@typescript-eslint/visitor-keys": "8.32.1", + "@typescript-eslint/scope-manager": "8.33.1", + "@typescript-eslint/types": "8.33.1", + "@typescript-eslint/typescript-estree": "8.33.1", + "@typescript-eslint/visitor-keys": "8.33.1", "debug": "^4.3.4" }, "engines": { @@ -1897,15 +1900,37 @@ "typescript": ">=4.8.4 <5.9.0" } }, - "node_modules/@typescript-eslint/scope-manager": { - "version": "8.32.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.32.1.tgz", - "integrity": "sha512-7IsIaIDeZn7kffk7qXC3o6Z4UblZJKV3UBpkvRNpr5NSyLji7tvTcvmnMNYuYLyh26mN8W723xpo3i4MlD33vA==", + "node_modules/@typescript-eslint/project-service": { + "version": "8.33.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.33.1.tgz", + "integrity": "sha512-DZR0efeNklDIHHGRpMpR5gJITQpu6tLr9lDJnKdONTC7vvzOlLAG/wcfxcdxEWrbiZApcoBCzXqU/Z458Za5Iw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.32.1", - "@typescript-eslint/visitor-keys": "8.32.1" + "@typescript-eslint/tsconfig-utils": "^8.33.1", + "@typescript-eslint/types": "^8.33.1", + "debug": "^4.3.4" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <5.9.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.33.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.33.1.tgz", + "integrity": "sha512-dM4UBtgmzHR9bS0Rv09JST0RcHYearoEoo3pG5B6GoTR9XcyeqX87FEhPo+5kTvVfKCvfHaHrcgeJQc6mrDKrA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.33.1", + "@typescript-eslint/visitor-keys": "8.33.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1915,15 +1940,32 @@ "url": "https://opencollective.com/typescript-eslint" } }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.33.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.33.1.tgz", + "integrity": "sha512-STAQsGYbHCF0/e+ShUQ4EatXQ7ceh3fBCXkNU7/MZVKulrlq1usH7t2FhxvCpuCi5O5oi1vmVaAjrGeL71OK1g==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <5.9.0" + } + }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.32.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.32.1.tgz", - "integrity": "sha512-mv9YpQGA8iIsl5KyUPi+FGLm7+bA4fgXaeRcFKRDRwDMu4iwrSHeDPipwueNXhdIIZltwCJv+NkxftECbIZWfA==", + "version": "8.33.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.33.1.tgz", + "integrity": "sha512-1cG37d9xOkhlykom55WVwG2QRNC7YXlxMaMzqw2uPeJixBFfKWZgaP/hjAObqMN/u3fr5BrTwTnc31/L9jQ2ww==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/typescript-estree": "8.32.1", - "@typescript-eslint/utils": "8.32.1", + "@typescript-eslint/typescript-estree": "8.33.1", + "@typescript-eslint/utils": "8.33.1", "debug": "^4.3.4", "ts-api-utils": "^2.1.0" }, @@ -1940,9 +1982,9 @@ } }, "node_modules/@typescript-eslint/types": { - "version": "8.32.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.32.1.tgz", - "integrity": "sha512-YmybwXUJcgGqgAp6bEsgpPXEg6dcCyPyCSr0CAAueacR/CCBi25G3V8gGQ2kRzQRBNol7VQknxMs9HvVa9Rvfg==", + "version": "8.33.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.33.1.tgz", + "integrity": "sha512-xid1WfizGhy/TKMTwhtVOgalHwPtV8T32MS9MaH50Cwvz6x6YqRIPdD2WvW0XaqOzTV9p5xdLY0h/ZusU5Lokg==", "dev": true, "license": "MIT", "engines": { @@ -1954,14 +1996,16 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.32.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.32.1.tgz", - "integrity": "sha512-Y3AP9EIfYwBb4kWGb+simvPaqQoT5oJuzzj9m0i6FCY6SPvlomY2Ei4UEMm7+FXtlNJbor80ximyslzaQF6xhg==", + "version": "8.33.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.33.1.tgz", + "integrity": "sha512-+s9LYcT8LWjdYWu7IWs7FvUxpQ/DGkdjZeE/GGulHvv8rvYwQvVaUZ6DE+j5x/prADUgSbbCWZ2nPI3usuVeOA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.32.1", - "@typescript-eslint/visitor-keys": "8.32.1", + "@typescript-eslint/project-service": "8.33.1", + "@typescript-eslint/tsconfig-utils": "8.33.1", + "@typescript-eslint/types": "8.33.1", + "@typescript-eslint/visitor-keys": "8.33.1", "debug": "^4.3.4", "fast-glob": "^3.3.2", "is-glob": "^4.0.3", @@ -2007,16 +2051,16 @@ } }, "node_modules/@typescript-eslint/utils": { - "version": "8.32.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.32.1.tgz", - "integrity": "sha512-DsSFNIgLSrc89gpq1LJB7Hm1YpuhK086DRDJSNrewcGvYloWW1vZLHBTIvarKZDcAORIy/uWNx8Gad+4oMpkSA==", + "version": "8.33.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.33.1.tgz", + "integrity": "sha512-52HaBiEQUaRYqAXpfzWSR2U3gxk92Kw006+xZpElaPMg3C4PgM+A5LqwoQI1f9E5aZ/qlxAZxzm42WX+vn92SQ==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.7.0", - "@typescript-eslint/scope-manager": "8.32.1", - "@typescript-eslint/types": "8.32.1", - "@typescript-eslint/typescript-estree": "8.32.1" + "@typescript-eslint/scope-manager": "8.33.1", + "@typescript-eslint/types": "8.33.1", + "@typescript-eslint/typescript-estree": "8.33.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -2031,13 +2075,13 @@ } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.32.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.32.1.tgz", - "integrity": "sha512-ar0tjQfObzhSaW3C3QNmTc5ofj0hDoNQ5XWrCy6zDyabdr0TWhCkClp+rywGNj/odAFBVzzJrK4tEq5M4Hmu4w==", + "version": "8.33.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.33.1.tgz", + "integrity": "sha512-3i8NrFcZeeDHJ+7ZUuDkGT+UHq+XoFGsymNK2jZCOHcfEzRQ0BdpRtdpSx/Iyf3MHLWIcLS0COuOPibKQboIiQ==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.32.1", + "@typescript-eslint/types": "8.33.1", "eslint-visitor-keys": "^4.2.0" }, "engines": { @@ -3321,20 +3365,20 @@ } }, "node_modules/eslint": { - "version": "9.23.0", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.23.0.tgz", - "integrity": "sha512-jV7AbNoFPAY1EkFYpLq5bslU9NLNO8xnEeQXwErNibVryjk67wHVmddTBilc5srIttJDBrB0eMHKZBFbSIABCw==", + "version": "9.28.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.28.0.tgz", + "integrity": "sha512-ocgh41VhRlf9+fVpe7QKzwLj9c92fDiqOj8Y3Sd4/ZmVA4Btx4PlUYPq4pp9JDyupkf1upbEXecxL2mwNV7jPQ==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.12.1", - "@eslint/config-array": "^0.19.2", - "@eslint/config-helpers": "^0.2.0", - "@eslint/core": "^0.12.0", + "@eslint/config-array": "^0.20.0", + "@eslint/config-helpers": "^0.2.1", + "@eslint/core": "^0.14.0", "@eslint/eslintrc": "^3.3.1", - "@eslint/js": "9.23.0", - "@eslint/plugin-kit": "^0.2.7", + "@eslint/js": "9.28.0", + "@eslint/plugin-kit": "^0.3.1", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", @@ -5469,9 +5513,9 @@ } }, "node_modules/npm-check-updates": { - "version": "17.1.16", - "resolved": "https://registry.npmjs.org/npm-check-updates/-/npm-check-updates-17.1.16.tgz", - "integrity": "sha512-9nohkfjLRzLfsLVGbO34eXBejvrOOTuw5tvNammH73KEFG5XlFoi3G2TgjTExHtnrKWCbZ+mTT+dbNeSjASIPw==", + "version": "18.0.1", + "resolved": "https://registry.npmjs.org/npm-check-updates/-/npm-check-updates-18.0.1.tgz", + "integrity": "sha512-MO7mLp/8nm6kZNLLyPgz4gHmr9tLoU+pWPLdXuGAx+oZydBHkHWN0ibTonsrfwC2WEQNIQxuZagYwB67JQpAuw==", "dev": true, "license": "Apache-2.0", "bin": { @@ -6284,9 +6328,9 @@ "license": "MIT" }, "node_modules/sass": { - "version": "1.89.0", - "resolved": "https://registry.npmjs.org/sass/-/sass-1.89.0.tgz", - "integrity": "sha512-ld+kQU8YTdGNjOLfRWBzewJpU5cwEv/h5yyqlSeJcj6Yh8U4TDA9UA5FPicqDz/xgRPWRSYIQNiFks21TbA9KQ==", + "version": "1.89.1", + "resolved": "https://registry.npmjs.org/sass/-/sass-1.89.1.tgz", + "integrity": "sha512-eMLLkl+qz7tx/0cJ9wI+w09GQ2zodTkcE/aVfywwdlRcI3EO19xGnbmJwg/JMIm+5MxVJ6outddLZ4Von4E++Q==", "dev": true, "license": "MIT", "dependencies": { @@ -7078,15 +7122,15 @@ } }, "node_modules/typescript-eslint": { - "version": "8.32.1", - "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.32.1.tgz", - "integrity": "sha512-D7el+eaDHAmXvrZBy1zpzSNIRqnCOrkwTgZxTu3MUqRWk8k0q9m9Ho4+vPf7iHtgUfrK/o8IZaEApsxPlHTFCg==", + "version": "8.33.1", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.33.1.tgz", + "integrity": "sha512-AgRnV4sKkWOiZ0Kjbnf5ytTJXMUZQ0qhSVdQtDNYLPLnjsATEYhaO94GlRQwi4t4gO8FfjM6NnikHeKjUm8D7A==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/eslint-plugin": "8.32.1", - "@typescript-eslint/parser": "8.32.1", - "@typescript-eslint/utils": "8.32.1" + "@typescript-eslint/eslint-plugin": "8.33.1", + "@typescript-eslint/parser": "8.33.1", + "@typescript-eslint/utils": "8.33.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -7277,14 +7321,15 @@ } }, "node_modules/webpack": { - "version": "5.98.0", - "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.98.0.tgz", - "integrity": "sha512-UFynvx+gM44Gv9qFgj0acCQK2VE1CtdfwFdimkapco3hlPCJ/zeq73n2yVKimVbtm+TnApIugGhLJnkU6gjYXA==", + "version": "5.99.9", + "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.99.9.tgz", + "integrity": "sha512-brOPwM3JnmOa+7kd3NsmOUOwbDAj8FT9xDsG3IW0MgbN9yZV7Oi/s/+MNQ/EcSMqw7qfoRyXPoeEWT8zLVdVGg==", "dev": true, "license": "MIT", "dependencies": { "@types/eslint-scope": "^3.7.7", "@types/estree": "^1.0.6", + "@types/json-schema": "^7.0.15", "@webassemblyjs/ast": "^1.14.1", "@webassemblyjs/wasm-edit": "^1.14.1", "@webassemblyjs/wasm-parser": "^1.14.1", @@ -7301,7 +7346,7 @@ "loader-runner": "^4.2.0", "mime-types": "^2.1.27", "neo-async": "^2.6.2", - "schema-utils": "^4.3.0", + "schema-utils": "^4.3.2", "tapable": "^2.1.1", "terser-webpack-plugin": "^5.3.11", "watchpack": "^2.4.1", @@ -7470,9 +7515,9 @@ "license": "MIT" }, "node_modules/webpack/node_modules/schema-utils": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.3.0.tgz", - "integrity": "sha512-Gf9qqc58SpCA/xdziiHz35F4GNIWYWZrEshUc/G/r5BnLph6xpKuLeoJoQuj5WfBIx/eQLf+hmVPYHaxJu7V2g==", + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.3.2.tgz", + "integrity": "sha512-Gn/JaSk/Mt9gYubxTtSn/QCV4em9mpAPiR1rqy/Ocu19u/G9J5WWdNoUT4SiV6mFC3y6cxyFcFwdzPM3FgxGAQ==", "dev": true, "license": "MIT", "dependencies": { @@ -7562,9 +7607,9 @@ } }, "node_modules/ws": { - "version": "8.18.1", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.1.tgz", - "integrity": "sha512-RKW2aJZMXeMxVpnZ6bck+RswznaxmzdULiBr6KY7XkTnW8uvt0iT9H5DkHUChXrc+uurzwa0rVI16n/Xzjdz1w==", + "version": "8.18.2", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.2.tgz", + "integrity": "sha512-DMricUmwGZUVr++AEAe2uiVM7UoO9MAVZMDu05UQOaUII0lp+zOzLLU4Xqh/JvTqklB1T4uELaaPBKyjE1r4fQ==", "dev": true, "license": "MIT", "engines": { @@ -7648,7 +7693,7 @@ "license": "MIT", "devDependencies": { "@types/jest": "^29.5.14", - "@types/node": "^22.14.0", + "@types/node": "^22.15.30", "css-loader": "^7.1.2", "date-fns": "^4.1.0", "file-loader": "^6.2.0", @@ -7657,7 +7702,7 @@ "mini-css-extract-plugin": "^2.9.2", "obsidian": "1.8.7", "resolve-url-loader": "^5.0.0", - "sass": "^1.89.0", + "sass": "^1.89.1", "sass-loader": "^16.0.5", "sync-client": "file:../sync-client", "terser-webpack-plugin": "^5.3.14", @@ -7667,7 +7712,7 @@ "typescript": "5.8.3", "url": "^0.11.4", "virtual-scroller": "^1.13.1", - "webpack": "^5.98.0", + "webpack": "^5.99.9", "webpack-cli": "^6.0.1" } }, @@ -7681,17 +7726,17 @@ }, "devDependencies": { "@types/jest": "^29.5.14", - "@types/node": "^22.14.0", + "@types/node": "^22.15.30", "jest": "^29.7.0", "sync_lib": "file:../../backend/sync_lib/pkg", "ts-jest": "^29.3.4", "ts-loader": "^9.5.2", "tslib": "2.8.1", "typescript": "5.8.3", - "webpack": "^5.98.0", + "webpack": "^5.99.9", "webpack-cli": "^6.0.1", "webpack-merge": "^6.0.1", - "ws": "^8.18.1" + "ws": "^8.18.2" } }, "sync-client/node_modules/brace-expansion": { @@ -7724,14 +7769,14 @@ "test-client": "dist/cli.js" }, "devDependencies": { - "@types/node": "^22.14.0", + "@types/node": "^22.15.30", "bufferutil": "^4.0.9", "sync-client": "file:../sync-client", "ts-loader": "^9.5.2", "tslib": "2.8.1", "typescript": "5.8.3", "uuid": "^11.1.0", - "webpack": "^5.98.0", + "webpack": "^5.99.9", "webpack-cli": "^6.0.1" } } diff --git a/frontend/package.json b/frontend/package.json index 7a542ef0..6c51ddcf 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -21,10 +21,10 @@ }, "devDependencies": { "concurrently": "^9.1.2", - "eslint": "9.23.0", + "eslint": "9.28.0", "eslint-plugin-unused-imports": "^4.1.4", - "npm-check-updates": "^17.1.16", + "npm-check-updates": "^18.0.1", "prettier": "^3.5.3", - "typescript-eslint": "8.32.1" + "typescript-eslint": "8.33.1" } } \ No newline at end of file diff --git a/frontend/sync-client/package.json b/frontend/sync-client/package.json index ab01b233..4c4b2ca0 100644 --- a/frontend/sync-client/package.json +++ b/frontend/sync-client/package.json @@ -20,16 +20,16 @@ }, "devDependencies": { "@types/jest": "^29.5.14", - "@types/node": "^22.14.0", + "@types/node": "^22.15.30", "jest": "^29.7.0", "sync_lib": "file:../../backend/sync_lib/pkg", "ts-jest": "^29.3.4", "ts-loader": "^9.5.2", "tslib": "2.8.1", "typescript": "5.8.3", - "webpack": "^5.98.0", + "webpack": "^5.99.9", "webpack-cli": "^6.0.1", "webpack-merge": "^6.0.1", - "ws": "^8.18.1" + "ws": "^8.18.2" } } \ No newline at end of file diff --git a/frontend/test-client/package.json b/frontend/test-client/package.json index 73c6cd7d..3c863be6 100644 --- a/frontend/test-client/package.json +++ b/frontend/test-client/package.json @@ -11,13 +11,13 @@ "test": "jest" }, "devDependencies": { - "@types/node": "^22.14.0", + "@types/node": "^22.15.30", "sync-client": "file:../sync-client", "ts-loader": "^9.5.2", "tslib": "2.8.1", "typescript": "5.8.3", "uuid": "^11.1.0", - "webpack": "^5.98.0", + "webpack": "^5.99.9", "webpack-cli": "^6.0.1", "bufferutil": "^4.0.9" } -- 2.47.2 From 4f691b33a44db19cf2e6274491c6ec29bb8912a5 Mon Sep 17 00:00:00 2001 From: Andras Schmelczer Date: Sat, 7 Jun 2025 21:37:44 +0100 Subject: [PATCH 20/29] Fix type serialisation --- backend/sync_server/src/app_state/database/models.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/backend/sync_server/src/app_state/database/models.rs b/backend/sync_server/src/app_state/database/models.rs index bf555d0e..e995611e 100644 --- a/backend/sync_server/src/app_state/database/models.rs +++ b/backend/sync_server/src/app_state/database/models.rs @@ -61,7 +61,9 @@ impl From for DocumentVersionWithoutContent { #[derive(TS, Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct DocumentVersion { + #[ts(as = "i32")] pub vault_update_id: VaultUpdateId, + pub document_id: DocumentId, pub relative_path: String, pub updated_date: DateTime, -- 2.47.2 From 14db4bf2404b9791cde0c2a219fbc804d1ff94c0 Mon Sep 17 00:00:00 2001 From: Andras Schmelczer Date: Sat, 7 Jun 2025 21:51:14 +0100 Subject: [PATCH 21/29] Extract getCursorsFromEditor --- .../src/obsidian-file-system.ts | 26 +++++++------------ .../src/utils/get-cursors-from-editor.ts | 17 ++++++++++++ 2 files changed, 27 insertions(+), 16 deletions(-) create mode 100644 frontend/obsidian-plugin/src/utils/get-cursors-from-editor.ts diff --git a/frontend/obsidian-plugin/src/obsidian-file-system.ts b/frontend/obsidian-plugin/src/obsidian-file-system.ts index 9905b036..6546b0fb 100644 --- a/frontend/obsidian-plugin/src/obsidian-file-system.ts +++ b/frontend/obsidian-plugin/src/obsidian-file-system.ts @@ -7,6 +7,7 @@ import type { } from "sync-client"; import { lineAndColumnToPosition } from "./utils/line-and-column-to-position"; import { positionToLineAndColumn } from "./utils/position-to-line-and-column"; +import { getCursorsFromEditor } from "./utils/get-cursors-from-editor"; export class ObsidianFileSystemOperations implements FileSystemOperations { public constructor( @@ -78,26 +79,19 @@ export class ObsidianFileSystemOperations implements FileSystemOperations { if (view?.file?.path === path) { const text = view.editor.getValue(); - const cursors = view.editor - .listSelections() - .flatMap(({ anchor, head }, i) => [ + + const cursors = getCursorsFromEditor(view.editor).flatMap( + ({ id, start: anchor, end: head }) => [ { - id: 2 * i, - characterPosition: lineAndColumnToPosition( - text, - anchor.line, - anchor.ch - ) + id: 2 * id, + characterPosition: anchor }, { - id: 2 * i + 1, - characterPosition: lineAndColumnToPosition( - text, - head.line, - head.ch - ) + id: 2 * id + 1, + characterPosition: head } - ]); + ] + ); const result = updater({ text, diff --git a/frontend/obsidian-plugin/src/utils/get-cursors-from-editor.ts b/frontend/obsidian-plugin/src/utils/get-cursors-from-editor.ts new file mode 100644 index 00000000..8844942a --- /dev/null +++ b/frontend/obsidian-plugin/src/utils/get-cursors-from-editor.ts @@ -0,0 +1,17 @@ +import { Editor } from "obsidian"; +import { lineAndColumnToPosition } from "./line-and-column-to-position"; + +export interface Cursor { + id: number; + start: number; + end: number; +} + +export function getCursorsFromEditor(editor: Editor): Cursor[] { + const text = editor.getValue(); + return editor.listSelections().map(({ anchor, head }, i) => ({ + id: i, + start: lineAndColumnToPosition(text, anchor.line, anchor.ch), + end: lineAndColumnToPosition(text, head.line, head.ch) + })); +} -- 2.47.2 From 02f32e894a13b07fd46baaaa735dc7ddd6e648aa Mon Sep 17 00:00:00 2001 From: Andras Schmelczer Date: Sat, 7 Jun 2025 22:13:07 +0100 Subject: [PATCH 22/29] Return user name for cursors rather than device --- backend/sync_server/src/app_state/cursors.rs | 2 ++ .../src/app_state/websocket/models.rs | 1 + .../src/app_state/websocket/utils.rs | 12 +++++++--- backend/sync_server/src/server/websocket.rs | 22 ++++++++++++------- .../src/services/types/ClientCursors.ts | 2 +- 5 files changed, 27 insertions(+), 12 deletions(-) diff --git a/backend/sync_server/src/app_state/cursors.rs b/backend/sync_server/src/app_state/cursors.rs index a48aceec..a2dc6807 100644 --- a/backend/sync_server/src/app_state/cursors.rs +++ b/backend/sync_server/src/app_state/cursors.rs @@ -34,6 +34,7 @@ impl Cursors { pub async fn update_cursors( &self, vault_id: VaultId, + user_name: String, device_id: &DeviceId, document_to_cursors: HashMap>, ) { @@ -43,6 +44,7 @@ impl Cursors { all_device_cursors.retain(|c| &c.client_cursors.device_id != device_id); all_device_cursors.push(ClientCursorsWithTimeToLive::new(ClientCursors { + user_name, device_id: device_id.to_string(), cursors: document_to_cursors, })); diff --git a/backend/sync_server/src/app_state/websocket/models.rs b/backend/sync_server/src/app_state/websocket/models.rs index 0b8e1828..6bb4f4e1 100644 --- a/backend/sync_server/src/app_state/websocket/models.rs +++ b/backend/sync_server/src/app_state/websocket/models.rs @@ -31,6 +31,7 @@ pub struct CursorPositionFromClient { #[derive(TS, Serialize, Clone, Debug)] #[serde(rename_all = "camelCase")] pub struct ClientCursors { + pub user_name: String, pub device_id: DeviceId, pub cursors: HashMap>, } diff --git a/backend/sync_server/src/app_state/websocket/utils.rs b/backend/sync_server/src/app_state/websocket/utils.rs index cf337e39..1e0dd243 100644 --- a/backend/sync_server/src/app_state/websocket/utils.rs +++ b/backend/sync_server/src/app_state/websocket/utils.rs @@ -8,15 +8,21 @@ use crate::{ AppState, database::models::{DocumentVersionWithoutContent, VaultId, VaultUpdateId}, }, + config::user_config::User, errors::{SyncServerError, server_error, unauthenticated_error}, server::auth::auth, }; +pub struct AuthenticatedWebSocketHandshake { + pub handshake: WebSocketHandshake, + pub user: User, +} + pub fn get_authenticated_handshake( state: &AppState, vault_id: &VaultId, message: Option, -) -> Result { +) -> Result { if let Some(Message::Text(message)) = message { let message: WebSocketClientMessage = serde_json::from_str(&message) .context("Failed to parse message") @@ -24,8 +30,8 @@ pub fn get_authenticated_handshake( match message { WebSocketClientMessage::Handshake(handshake) => { - auth(state, handshake.token.trim(), vault_id)?; - Ok(handshake) + let user = auth(state, handshake.token.trim(), vault_id)?; + Ok(AuthenticatedWebSocketHandshake { handshake, user }) } WebSocketClientMessage::CursorPositions(_) => Err(unauthenticated_error( anyhow::anyhow!("Expected a handshake message"), diff --git a/backend/sync_server/src/server/websocket.rs b/backend/sync_server/src/server/websocket.rs index cfe7f8f9..e9dd8867 100644 --- a/backend/sync_server/src/server/websocket.rs +++ b/backend/sync_server/src/server/websocket.rs @@ -52,6 +52,7 @@ async fn websocket_wrapped(state: AppState, stream: WebSocket, vault_id: VaultId } } +#[allow(clippy::too_many_lines)] async fn websocket( state: AppState, stream: WebSocket, @@ -59,7 +60,7 @@ async fn websocket( ) -> Result<(), SyncServerError> { let (mut sender, mut websocket_receiver) = stream.split(); - let handshake = get_authenticated_handshake( + let authed_handshake = get_authenticated_handshake( &state, &vault_id, websocket_receiver @@ -71,15 +72,19 @@ async fn websocket( info!( "WebSocket handshake successful for vault '{vault_id}' for '{}'", - handshake.device_id + authed_handshake.handshake.device_id ); let mut broadcast_receiver = state.broadcasts.get_receiver(vault_id.clone()).await; send_update_over_websocket( &WebSocketServerMessage::VaultUpdate(WebSocketVaultUpdate { - documents: get_unseen_documents(&state, &vault_id, handshake.last_seen_vault_update_id) - .await?, + documents: get_unseen_documents( + &state, + &vault_id, + authed_handshake.handshake.last_seen_vault_update_id, + ) + .await?, is_initial_sync: true, }), &mut sender, @@ -94,7 +99,7 @@ async fn websocket( ) .await?; - let device_id = handshake.device_id.clone(); + let device_id = authed_handshake.handshake.device_id.clone(); let mut send_task = tokio::spawn(async move { while let Ok(update) = broadcast_receiver.recv().await { if Some(&device_id) == update.origin_device_id.as_ref() { @@ -107,7 +112,7 @@ async fn websocket( Ok::<(), SyncServerError>(()) }); - let device_id = handshake.device_id.clone(); + let device_id = authed_handshake.handshake.device_id.clone(); let vault_id_clone = vault_id.clone(); let cursor_manager = state.cursors.clone(); let mut receive_task = tokio::spawn(async move { @@ -126,6 +131,7 @@ async fn websocket( cursor_manager .update_cursors( vault_id_clone.clone(), + authed_handshake.user.name.clone(), &device_id, cursors.document_to_cursors, ) @@ -161,13 +167,13 @@ async fn websocket( state .cursors - .remove_cursors_of_device(&vault_id, &handshake.device_id) + .remove_cursors_of_device(&vault_id, &authed_handshake.handshake.device_id) .await; if result.is_err() { info!( "WebSocket disconnected on vault '{vault_id}' for '{}'", - handshake.device_id + authed_handshake.handshake.device_id ); } diff --git a/frontend/sync-client/src/services/types/ClientCursors.ts b/frontend/sync-client/src/services/types/ClientCursors.ts index 87ebebdf..a70420b8 100644 --- a/frontend/sync-client/src/services/types/ClientCursors.ts +++ b/frontend/sync-client/src/services/types/ClientCursors.ts @@ -1,4 +1,4 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. import type { CursorSpan } from "./CursorSpan"; -export type ClientCursors = { deviceId: string, cursors: { [key in string]?: Array }, }; +export type ClientCursors = { userName: string, deviceId: string, cursors: { [key in string]?: Array }, }; -- 2.47.2 From 7e3f972531f235cc71d3f08570d9f325ccf9b2c7 Mon Sep 17 00:00:00 2001 From: Andras Schmelczer Date: Sat, 7 Jun 2025 22:14:30 +0100 Subject: [PATCH 23/29] Expose cursor management --- frontend/sync-client/src/index.ts | 3 ++- .../src/services/websocket-manager.ts | 23 +++++++++++++++---- frontend/sync-client/src/sync-client.ts | 14 +++++++++++ 3 files changed, 34 insertions(+), 6 deletions(-) diff --git a/frontend/sync-client/src/index.ts b/frontend/sync-client/src/index.ts index 7079f707..0cd94277 100644 --- a/frontend/sync-client/src/index.ts +++ b/frontend/sync-client/src/index.ts @@ -19,7 +19,8 @@ export type { Cursor } from "./file-operations/filesystem-operations"; export type { PersistenceProvider } from "./persistence/persistence"; - +export type { CursorSpan } from "./services/types/CursorSpan"; +export type { ClientCursors } from "./services/types/ClientCursors"; export type { NetworkConnectionStatus } from "./types/network-connection-status"; export { DocumentUpdateStatus } from "./types/document-update-status"; export { SyncClient } from "./sync-client"; diff --git a/frontend/sync-client/src/services/websocket-manager.ts b/frontend/sync-client/src/services/websocket-manager.ts index 8a37f9ff..0006e344 100644 --- a/frontend/sync-client/src/services/websocket-manager.ts +++ b/frontend/sync-client/src/services/websocket-manager.ts @@ -5,10 +5,13 @@ import { WebSocketServerMessage } from "./types/WebSocketServerMessage"; import { Syncer } from "../sync-operations/syncer"; import { WebSocketClientMessage } from "./types/WebSocketClientMessage"; import { CursorPositionFromClient } from "./types/CursorPositionFromClient"; +import { ClientCursors } from "./types/ClientCursors"; export class WebSocketManager { private readonly webSocketStatusChangeListeners: (() => unknown)[] = []; - // private readonly cur: (() => unknown)[] = []; + private readonly remoteCursorsUpdateListeners: (( + cursors: ClientCursors[] + ) => unknown)[] = []; private refreshWebSocketInterval: NodeJS.Timeout | undefined; @@ -66,6 +69,12 @@ export class WebSocketManager { this.webSocketStatusChangeListeners.push(listener); } + public addRemoteCursorsUpdateListener( + listener: (cursors: ClientCursors[]) => void + ): void { + this.remoteCursorsUpdateListeners.push(listener); + } + public async reset(): Promise { this.setWebSocketRefreshInterval(); this.updateWebSocket(this.settings.getSettings()); @@ -129,7 +138,13 @@ export class WebSocketManager { this.logger.info( `Received cursor positions for ${JSON.stringify(message.clients)}` ); - // Handle cursor positions if needed + this.remoteCursorsUpdateListeners.forEach((listener) => { + listener( + message.clients.filter( + (client) => client.deviceId !== this.deviceId + ) + ); + }); } else { this.logger.warn( `Received unknown message type: ${JSON.stringify(message)}` @@ -163,9 +178,7 @@ export class WebSocketManager { }; } - public sendCursorPositions( - cursorPositions: CursorPositionFromClient - ): void { + public updateLocalCursors(cursorPositions: CursorPositionFromClient): void { if (!this.isWebSocketConnected) { this.logger.warn( "WebSocket is not connected, cannot send cursor positions" diff --git a/frontend/sync-client/src/sync-client.ts b/frontend/sync-client/src/sync-client.ts index b9fbbcc2..1bb92f3c 100644 --- a/frontend/sync-client/src/sync-client.ts +++ b/frontend/sync-client/src/sync-client.ts @@ -19,6 +19,8 @@ import type { NetworkConnectionStatus } from "./types/network-connection-status" import { DocumentUpdateStatus } from "./types/document-update-status"; import { WebSocketManager } from "./services/websocket-manager"; import { createClientId } from "./utils/create-client-id"; +import { CursorSpan } from "./services/types/CursorSpan"; +import { ClientCursors } from "./services/types/ClientCursors"; export class SyncClient { private static readonly MINIMUM_SAVE_INTERVAL_MS = 1000; @@ -273,6 +275,18 @@ export class SyncClient { }); } + public async updateLocalCursors(documentToCursors: { + [path: RelativePath]: CursorSpan[]; + }): Promise { + return this.webSocketManager.updateLocalCursors({ documentToCursors }); + } + + public addRemoteCursorsUpdateListener( + listener: (cursors: ClientCursors[]) => void + ): void { + this.webSocketManager.addRemoteCursorsUpdateListener(listener); + } + public getDocumentSyncingStatus( relativePath: RelativePath ): DocumentUpdateStatus { -- 2.47.2 From 57b2b7693288aa42ea2ba0012323bd1f4664e751 Mon Sep 17 00:00:00 2001 From: Andras Schmelczer Date: Sat, 7 Jun 2025 22:24:27 +0100 Subject: [PATCH 24/29] Lint & format --- .../src/utils/get-cursors-from-editor.ts | 2 +- .../src/utils/get-random-color.ts | 9 ++++ .../sync-client/src/services/sync-service.ts | 52 +++++++++++-------- .../src/services/types/ClientCursors.ts | 2 +- .../services/types/CreateDocumentVersion.ts | 4 +- .../types/CursorPositionFromClient.ts | 2 +- .../types/CursorPositionFromServer.ts | 2 +- .../src/services/types/CursorSpan.ts | 2 +- .../services/types/DeleteDocumentVersion.ts | 2 +- .../src/services/types/DocumentVersion.ts | 2 +- .../types/DocumentVersionWithoutContent.ts | 2 +- .../types/FetchLatestDocumentsResponse.ts | 4 +- .../src/services/types/PingResponse.ts | 4 +- .../src/services/types/SerializedError.ts | 2 +- .../services/types/UpdateDocumentVersion.ts | 2 +- .../src/services/types/WebSocketHandshake.ts | 2 +- .../services/types/WebSocketVaultUpdate.ts | 2 +- .../src/services/websocket-manager.ts | 48 ++++++++--------- frontend/sync-client/src/sync-client.ts | 10 ++-- .../sync-client/src/sync-operations/syncer.ts | 2 +- .../sync-operations/unrestricted-syncer.ts | 6 +-- 21 files changed, 89 insertions(+), 74 deletions(-) create mode 100644 frontend/obsidian-plugin/src/utils/get-random-color.ts diff --git a/frontend/obsidian-plugin/src/utils/get-cursors-from-editor.ts b/frontend/obsidian-plugin/src/utils/get-cursors-from-editor.ts index 8844942a..62113d1b 100644 --- a/frontend/obsidian-plugin/src/utils/get-cursors-from-editor.ts +++ b/frontend/obsidian-plugin/src/utils/get-cursors-from-editor.ts @@ -1,4 +1,4 @@ -import { Editor } from "obsidian"; +import type { Editor } from "obsidian"; import { lineAndColumnToPosition } from "./line-and-column-to-position"; export interface Cursor { diff --git a/frontend/obsidian-plugin/src/utils/get-random-color.ts b/frontend/obsidian-plugin/src/utils/get-random-color.ts new file mode 100644 index 00000000..eadd0927 --- /dev/null +++ b/frontend/obsidian-plugin/src/utils/get-random-color.ts @@ -0,0 +1,9 @@ +export function getRandomColor(name: string): string { + let hash = 0; + for (let i = 0; i < name.length; i++) { + hash = (hash << 5) - hash + name.charCodeAt(i); + hash |= 0; // Convert to 32bit integer + } + const normalised = hash / 0x7fffffff; + return `hsl(${Math.abs(normalised * 360)}, 70%, 30%)`; // HSL color +} diff --git a/frontend/sync-client/src/services/sync-service.ts b/frontend/sync-client/src/services/sync-service.ts index 3d1cfadb..5ac81d5b 100644 --- a/frontend/sync-client/src/services/sync-service.ts +++ b/frontend/sync-client/src/services/sync-service.ts @@ -9,13 +9,13 @@ import type { Settings } from "../persistence/settings"; import type { ConnectionStatus } from "./connection-status"; import { sleep } from "../utils/sleep"; import { SyncResetError } from "./sync-reset-error"; -import { SerializedError } from "./types/SerializedError"; -import { DocumentVersionWithoutContent } from "./types/DocumentVersionWithoutContent"; -import { DocumentUpdateResponse } from "./types/DocumentUpdateResponse"; -import { DocumentVersion } from "./types/DocumentVersion"; -import { FetchLatestDocumentsResponse } from "./types/FetchLatestDocumentsResponse"; -import { PingResponse } from "./types/PingResponse"; -import { DeleteDocumentVersion } from "./types/DeleteDocumentVersion"; +import type { SerializedError } from "./types/SerializedError"; +import type { DocumentVersionWithoutContent } from "./types/DocumentVersionWithoutContent"; +import type { DocumentUpdateResponse } from "./types/DocumentUpdateResponse"; +import type { DocumentVersion } from "./types/DocumentVersion"; +import type { FetchLatestDocumentsResponse } from "./types/FetchLatestDocumentsResponse"; +import type { PingResponse } from "./types/PingResponse"; +import type { DeleteDocumentVersion } from "./types/DeleteDocumentVersion"; export interface CheckConnectionResult { isSuccessful: boolean; @@ -24,8 +24,8 @@ export interface CheckConnectionResult { export class SyncService { private static readonly NETWORK_RETRY_INTERVAL_MS = 1000; - private client: typeof globalThis.fetch; - private pingClient: typeof globalThis.fetch; + private readonly client: typeof globalThis.fetch; + private readonly pingClient: typeof globalThis.fetch; public constructor( private readonly deviceId: string, @@ -35,7 +35,7 @@ export class SyncService { fetchImplementation: typeof globalThis.fetch = globalThis.fetch ) { // ensure that if it's called a method, `this` won't be bound to the instance - const unboundFetch: typeof globalThis.fetch = (...args) => + const unboundFetch: typeof globalThis.fetch = async (...args) => fetchImplementation(...args); this.client = this.connectionStatus.getFetchImplementation( @@ -45,12 +45,6 @@ export class SyncService { this.pingClient = unboundFetch; } - private getUrl(path: string): string { - let { vaultName, remoteUri } = this.settings.getSettings(); - remoteUri = remoteUri.replace(/\/+$/, ""); - return `${remoteUri}/vaults/${vaultName}${path}`; - } - private static formatError(error: SerializedError): string { let result = error.message; if (error.causes.length > 0) { @@ -85,7 +79,9 @@ export class SyncService { }); const result: SerializedError | DocumentVersionWithoutContent = - await response.json(); + (await response.json()) as // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion + | SerializedError + | DocumentVersionWithoutContent; if ("errorType" in result) { throw new Error( @@ -133,7 +129,9 @@ export class SyncService { ); const result: SerializedError | DocumentUpdateResponse = - await response.json(); + (await response.json()) as // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion + | SerializedError + | DocumentUpdateResponse; if ("errorType" in result) { throw new Error( @@ -175,7 +173,9 @@ export class SyncService { ); const result: SerializedError | DocumentVersionWithoutContent = - await response.json(); + (await response.json()) as // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion + | SerializedError + | DocumentVersionWithoutContent; if ("errorType" in result) { throw new Error( @@ -205,7 +205,7 @@ export class SyncService { ); const result: SerializedError | DocumentVersion = - await response.json(); + (await response.json()) as SerializedError | DocumentVersion; // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion if ("errorType" in result) { throw new Error( @@ -234,7 +234,9 @@ export class SyncService { }); const result: SerializedError | FetchLatestDocumentsResponse = - await response.json(); + (await response.json()) as // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion + | SerializedError + | FetchLatestDocumentsResponse; if ("errorType" in result) { throw new Error( @@ -256,7 +258,7 @@ export class SyncService { headers: this.getDefaultHeaders() }); const result: PingResponse | SerializedError = - await response.json(); + (await response.json()) as PingResponse | SerializedError; // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion if ("errorType" in result) { throw new Error( @@ -283,6 +285,12 @@ export class SyncService { } } + private getUrl(path: string): string { + const { vaultName, remoteUri } = this.settings.getSettings(); + const safeRemoteUri = remoteUri.replace(/\/+$/, ""); + return `${safeRemoteUri}/vaults/${vaultName}${path}`; + } + private getDefaultHeaders(): Record { return { "device-id": this.deviceId, diff --git a/frontend/sync-client/src/services/types/ClientCursors.ts b/frontend/sync-client/src/services/types/ClientCursors.ts index a70420b8..cec4f064 100644 --- a/frontend/sync-client/src/services/types/ClientCursors.ts +++ b/frontend/sync-client/src/services/types/ClientCursors.ts @@ -1,4 +1,4 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. import type { CursorSpan } from "./CursorSpan"; -export type ClientCursors = { userName: string, deviceId: string, cursors: { [key in string]?: Array }, }; +export interface ClientCursors { userName: string, deviceId: string, cursors: Partial>, } diff --git a/frontend/sync-client/src/services/types/CreateDocumentVersion.ts b/frontend/sync-client/src/services/types/CreateDocumentVersion.ts index 4823105b..2a5ea1a0 100644 --- a/frontend/sync-client/src/services/types/CreateDocumentVersion.ts +++ b/frontend/sync-client/src/services/types/CreateDocumentVersion.ts @@ -1,10 +1,10 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -export type CreateDocumentVersion = { +export interface CreateDocumentVersion { /** * The client can decide the document id (if it wishes to) in order * to help with syncing. If the client does not provide a document id, * the server will generate one. If the client provides a document id * it must not already exist in the database. */ -document_id: string | null, relative_path: string, content: Array, }; +document_id: string | null, relative_path: string, content: number[], } diff --git a/frontend/sync-client/src/services/types/CursorPositionFromClient.ts b/frontend/sync-client/src/services/types/CursorPositionFromClient.ts index 87705d1c..f51b6603 100644 --- a/frontend/sync-client/src/services/types/CursorPositionFromClient.ts +++ b/frontend/sync-client/src/services/types/CursorPositionFromClient.ts @@ -1,4 +1,4 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. import type { CursorSpan } from "./CursorSpan"; -export type CursorPositionFromClient = { documentToCursors: { [key in string]?: Array }, }; +export interface CursorPositionFromClient { documentToCursors: Partial>, } diff --git a/frontend/sync-client/src/services/types/CursorPositionFromServer.ts b/frontend/sync-client/src/services/types/CursorPositionFromServer.ts index c8444892..ed6ac7b2 100644 --- a/frontend/sync-client/src/services/types/CursorPositionFromServer.ts +++ b/frontend/sync-client/src/services/types/CursorPositionFromServer.ts @@ -1,4 +1,4 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. import type { ClientCursors } from "./ClientCursors"; -export type CursorPositionFromServer = { clients: Array, }; +export interface CursorPositionFromServer { clients: ClientCursors[], } diff --git a/frontend/sync-client/src/services/types/CursorSpan.ts b/frontend/sync-client/src/services/types/CursorSpan.ts index d0bce6ea..7424067c 100644 --- a/frontend/sync-client/src/services/types/CursorSpan.ts +++ b/frontend/sync-client/src/services/types/CursorSpan.ts @@ -1,3 +1,3 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -export type CursorSpan = { start: number, end: number, }; +export interface CursorSpan { start: number, end: number, } diff --git a/frontend/sync-client/src/services/types/DeleteDocumentVersion.ts b/frontend/sync-client/src/services/types/DeleteDocumentVersion.ts index 6244f7ab..5d4bad98 100644 --- a/frontend/sync-client/src/services/types/DeleteDocumentVersion.ts +++ b/frontend/sync-client/src/services/types/DeleteDocumentVersion.ts @@ -1,3 +1,3 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -export type DeleteDocumentVersion = { relativePath: string, }; +export interface DeleteDocumentVersion { relativePath: string, } diff --git a/frontend/sync-client/src/services/types/DocumentVersion.ts b/frontend/sync-client/src/services/types/DocumentVersion.ts index 37bd32ca..3d50ae65 100644 --- a/frontend/sync-client/src/services/types/DocumentVersion.ts +++ b/frontend/sync-client/src/services/types/DocumentVersion.ts @@ -1,3 +1,3 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -export type DocumentVersion = { vaultUpdateId: number, documentId: string, relativePath: string, updatedDate: string, contentBase64: string, isDeleted: boolean, userId: string, deviceId: string, }; +export interface DocumentVersion { vaultUpdateId: number, documentId: string, relativePath: string, updatedDate: string, contentBase64: string, isDeleted: boolean, userId: string, deviceId: string, } diff --git a/frontend/sync-client/src/services/types/DocumentVersionWithoutContent.ts b/frontend/sync-client/src/services/types/DocumentVersionWithoutContent.ts index 03be2f63..af064db8 100644 --- a/frontend/sync-client/src/services/types/DocumentVersionWithoutContent.ts +++ b/frontend/sync-client/src/services/types/DocumentVersionWithoutContent.ts @@ -1,3 +1,3 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -export type DocumentVersionWithoutContent = { vaultUpdateId: number, documentId: string, relativePath: string, updatedDate: string, isDeleted: boolean, userId: string, deviceId: string, contentSize: number, }; +export interface DocumentVersionWithoutContent { vaultUpdateId: number, documentId: string, relativePath: string, updatedDate: string, isDeleted: boolean, userId: string, deviceId: string, contentSize: number, } diff --git a/frontend/sync-client/src/services/types/FetchLatestDocumentsResponse.ts b/frontend/sync-client/src/services/types/FetchLatestDocumentsResponse.ts index ce572684..3be625bd 100644 --- a/frontend/sync-client/src/services/types/FetchLatestDocumentsResponse.ts +++ b/frontend/sync-client/src/services/types/FetchLatestDocumentsResponse.ts @@ -4,8 +4,8 @@ import type { DocumentVersionWithoutContent } from "./DocumentVersionWithoutCont /** * Response to a fetch latest documents request. */ -export type FetchLatestDocumentsResponse = { latestDocuments: Array, +export interface FetchLatestDocumentsResponse { latestDocuments: DocumentVersionWithoutContent[], /** * The update ID of the latest document in the response. */ -lastUpdateId: bigint, }; +lastUpdateId: bigint, } diff --git a/frontend/sync-client/src/services/types/PingResponse.ts b/frontend/sync-client/src/services/types/PingResponse.ts index 6d3cba6e..7d64ea36 100644 --- a/frontend/sync-client/src/services/types/PingResponse.ts +++ b/frontend/sync-client/src/services/types/PingResponse.ts @@ -3,7 +3,7 @@ /** * Response to a ping request. */ -export type PingResponse = { +export interface PingResponse { /** * Semantic version of the server. */ @@ -12,4 +12,4 @@ serverVersion: string, * Whether the client is authenticated based on the sent Authorization * header. */ -isAuthenticated: boolean, }; +isAuthenticated: boolean, } diff --git a/frontend/sync-client/src/services/types/SerializedError.ts b/frontend/sync-client/src/services/types/SerializedError.ts index 5e3fa9b9..4389289e 100644 --- a/frontend/sync-client/src/services/types/SerializedError.ts +++ b/frontend/sync-client/src/services/types/SerializedError.ts @@ -1,3 +1,3 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -export type SerializedError = { errorType: string, message: string, causes: Array, }; +export interface SerializedError { errorType: string, message: string, causes: string[], } diff --git a/frontend/sync-client/src/services/types/UpdateDocumentVersion.ts b/frontend/sync-client/src/services/types/UpdateDocumentVersion.ts index 482e281a..e0ddd5ac 100644 --- a/frontend/sync-client/src/services/types/UpdateDocumentVersion.ts +++ b/frontend/sync-client/src/services/types/UpdateDocumentVersion.ts @@ -1,3 +1,3 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -export type UpdateDocumentVersion = { parent_version_id: bigint, relative_path: string, content: Array, }; +export interface UpdateDocumentVersion { parent_version_id: bigint, relative_path: string, content: number[], } diff --git a/frontend/sync-client/src/services/types/WebSocketHandshake.ts b/frontend/sync-client/src/services/types/WebSocketHandshake.ts index 85c2cf0d..d25651f9 100644 --- a/frontend/sync-client/src/services/types/WebSocketHandshake.ts +++ b/frontend/sync-client/src/services/types/WebSocketHandshake.ts @@ -1,3 +1,3 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -export type WebSocketHandshake = { token: string, deviceId: string, lastSeenVaultUpdateId: number | null, }; +export interface WebSocketHandshake { token: string, deviceId: string, lastSeenVaultUpdateId: number | null, } diff --git a/frontend/sync-client/src/services/types/WebSocketVaultUpdate.ts b/frontend/sync-client/src/services/types/WebSocketVaultUpdate.ts index b627ac3c..39e03b6f 100644 --- a/frontend/sync-client/src/services/types/WebSocketVaultUpdate.ts +++ b/frontend/sync-client/src/services/types/WebSocketVaultUpdate.ts @@ -1,4 +1,4 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. import type { DocumentVersionWithoutContent } from "./DocumentVersionWithoutContent"; -export type WebSocketVaultUpdate = { documents: Array, isInitialSync: boolean, }; +export interface WebSocketVaultUpdate { documents: DocumentVersionWithoutContent[], isInitialSync: boolean, } diff --git a/frontend/sync-client/src/services/websocket-manager.ts b/frontend/sync-client/src/services/websocket-manager.ts index 0006e344..285d51f9 100644 --- a/frontend/sync-client/src/services/websocket-manager.ts +++ b/frontend/sync-client/src/services/websocket-manager.ts @@ -1,11 +1,11 @@ import type { Database } from "../persistence/database"; import type { Logger } from "../tracing/logger"; import type { Settings, SyncSettings } from "../persistence/settings"; -import { WebSocketServerMessage } from "./types/WebSocketServerMessage"; -import { Syncer } from "../sync-operations/syncer"; -import { WebSocketClientMessage } from "./types/WebSocketClientMessage"; -import { CursorPositionFromClient } from "./types/CursorPositionFromClient"; -import { ClientCursors } from "./types/ClientCursors"; +import type { WebSocketServerMessage } from "./types/WebSocketServerMessage"; +import type { Syncer } from "../sync-operations/syncer"; +import type { WebSocketClientMessage } from "./types/WebSocketClientMessage"; +import type { CursorPositionFromClient } from "./types/CursorPositionFromClient"; +import type { ClientCursors } from "./types/ClientCursors"; export class WebSocketManager { private readonly webSocketStatusChangeListeners: (() => unknown)[] = []; @@ -19,7 +19,6 @@ export class WebSocketManager { private readonly webSocketFactoryImplementation: typeof globalThis.WebSocket; - // eslint-disable-next-line @typescript-eslint/max-params public constructor( private readonly deviceId: string, private readonly logger: Logger, @@ -90,6 +89,23 @@ export class WebSocketManager { } } + public updateLocalCursors(cursorPositions: CursorPositionFromClient): void { + if (!this.isWebSocketConnected) { + this.logger.warn( + "WebSocket is not connected, cannot send cursor positions" + ); + return; + } + const message: WebSocketClientMessage = { + type: "cursorPositions", + ...cursorPositions + }; + this.webSocket?.send(JSON.stringify(message)); + this.logger.info( + `Sent cursor positions: ${JSON.stringify(cursorPositions)}` + ); + } + private updateWebSocket(settings: SyncSettings): void { try { this.webSocket?.close(); @@ -134,6 +150,7 @@ export class WebSocketManager { `Failed to sync remotely updated file: ${e}` ); } + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition } else if (message.type === "cursorPositions") { this.logger.info( `Received cursor positions for ${JSON.stringify(message.clients)}` @@ -159,7 +176,7 @@ export class WebSocketManager { listener(); }); - let message: WebSocketClientMessage = { + const message: WebSocketClientMessage = { type: "handshake", deviceId: this.deviceId, token: settings.token, @@ -178,23 +195,6 @@ export class WebSocketManager { }; } - public updateLocalCursors(cursorPositions: CursorPositionFromClient): void { - if (!this.isWebSocketConnected) { - this.logger.warn( - "WebSocket is not connected, cannot send cursor positions" - ); - return; - } - let message: WebSocketClientMessage = { - type: "cursorPositions", - ...cursorPositions - }; - this.webSocket?.send(JSON.stringify(message)); - this.logger.info( - `Sent cursor positions: ${JSON.stringify(cursorPositions)}` - ); - } - private setWebSocketRefreshInterval(): void { this.refreshWebSocketInterval = setInterval(() => { if ( diff --git a/frontend/sync-client/src/sync-client.ts b/frontend/sync-client/src/sync-client.ts index 1bb92f3c..70e100ed 100644 --- a/frontend/sync-client/src/sync-client.ts +++ b/frontend/sync-client/src/sync-client.ts @@ -19,8 +19,8 @@ import type { NetworkConnectionStatus } from "./types/network-connection-status" import { DocumentUpdateStatus } from "./types/document-update-status"; import { WebSocketManager } from "./services/websocket-manager"; import { createClientId } from "./utils/create-client-id"; -import { CursorSpan } from "./services/types/CursorSpan"; -import { ClientCursors } from "./services/types/ClientCursors"; +import type { CursorSpan } from "./services/types/CursorSpan"; +import type { ClientCursors } from "./services/types/ClientCursors"; export class SyncClient { private static readonly MINIMUM_SAVE_INTERVAL_MS = 1000; @@ -275,10 +275,8 @@ export class SyncClient { }); } - public async updateLocalCursors(documentToCursors: { - [path: RelativePath]: CursorSpan[]; - }): Promise { - return this.webSocketManager.updateLocalCursors({ documentToCursors }); + public async updateLocalCursors(documentToCursors: Record): Promise { + this.webSocketManager.updateLocalCursors({ documentToCursors }); } public addRemoteCursorsUpdateListener( diff --git a/frontend/sync-client/src/sync-operations/syncer.ts b/frontend/sync-client/src/sync-operations/syncer.ts index 9270a8ed..30e012d9 100644 --- a/frontend/sync-client/src/sync-operations/syncer.ts +++ b/frontend/sync-client/src/sync-operations/syncer.ts @@ -16,7 +16,7 @@ import type { UnrestrictedSyncer } from "./unrestricted-syncer"; import { createPromise } from "../utils/create-promise"; import { SyncResetError } from "../services/sync-reset-error"; import { Locks } from "../utils/locks"; -import { DocumentVersionWithoutContent } from "../services/types/DocumentVersionWithoutContent"; +import type { DocumentVersionWithoutContent } from "../services/types/DocumentVersionWithoutContent"; export class Syncer { private readonly remoteDocumentsLock: Locks; diff --git a/frontend/sync-client/src/sync-operations/unrestricted-syncer.ts b/frontend/sync-client/src/sync-operations/unrestricted-syncer.ts index f892e640..0d0f45ef 100644 --- a/frontend/sync-client/src/sync-operations/unrestricted-syncer.ts +++ b/frontend/sync-client/src/sync-operations/unrestricted-syncer.ts @@ -24,9 +24,9 @@ import { createPromise } from "../utils/create-promise"; import { FileNotFoundError } from "../file-operations/file-not-found-error"; import { SyncResetError } from "../services/sync-reset-error"; import { globsToRegexes } from "../utils/globs-to-regexes"; -import { DocumentVersion } from "../services/types/DocumentVersion"; -import { DocumentUpdateResponse } from "../services/types/DocumentUpdateResponse"; -import { DocumentVersionWithoutContent } from "../services/types/DocumentVersionWithoutContent"; +import type { DocumentVersion } from "../services/types/DocumentVersion"; +import type { DocumentUpdateResponse } from "../services/types/DocumentUpdateResponse"; +import type { DocumentVersionWithoutContent } from "../services/types/DocumentVersionWithoutContent"; export class UnrestrictedSyncer { private ignorePatterns: RegExp[]; -- 2.47.2 From f4c77ddd25b40e48e1ba9e534a4c836d861acc48 Mon Sep 17 00:00:00 2001 From: Andras Schmelczer Date: Sun, 8 Jun 2025 11:32:41 +0100 Subject: [PATCH 25/29] Send cursors instantly --- backend/sync_server/src/app_state/cursors.rs | 5 +- .../src/obsidian-file-system.ts | 2 +- .../obsidian-plugin/src/vault-link-plugin.ts | 30 ++++++++-- .../cursors}/get-cursors-from-editor.ts | 2 +- .../cursors/local-cursor-update-listener.ts | 57 +++++++++++++++++++ 5 files changed, 87 insertions(+), 9 deletions(-) rename frontend/obsidian-plugin/src/{utils => views/cursors}/get-cursors-from-editor.ts (83%) create mode 100644 frontend/obsidian-plugin/src/views/cursors/local-cursor-update-listener.ts diff --git a/backend/sync_server/src/app_state/cursors.rs b/backend/sync_server/src/app_state/cursors.rs index a2dc6807..6b8a8605 100644 --- a/backend/sync_server/src/app_state/cursors.rs +++ b/backend/sync_server/src/app_state/cursors.rs @@ -48,6 +48,10 @@ impl Cursors { device_id: device_id.to_string(), cursors: document_to_cursors, })); + + drop(vault_to_cursors); // Explicitly drop the lock before broadcasting to avoid deadlock + + self.broadcast_cursors().await; } pub async fn get_cursors(&self, vault_id: &VaultId) -> Vec { @@ -73,7 +77,6 @@ impl Cursors { async fn run_backround_task(&self) { loop { self.remove_expired_cursors().await; - self.broadcast_cursors().await; tokio::time::sleep(self.config.cursor_broadcast_interval).await; } } diff --git a/frontend/obsidian-plugin/src/obsidian-file-system.ts b/frontend/obsidian-plugin/src/obsidian-file-system.ts index 6546b0fb..adf78a16 100644 --- a/frontend/obsidian-plugin/src/obsidian-file-system.ts +++ b/frontend/obsidian-plugin/src/obsidian-file-system.ts @@ -7,7 +7,7 @@ import type { } from "sync-client"; import { lineAndColumnToPosition } from "./utils/line-and-column-to-position"; import { positionToLineAndColumn } from "./utils/position-to-line-and-column"; -import { getCursorsFromEditor } from "./utils/get-cursors-from-editor"; +import { getCursorsFromEditor } from "./views/cursors/get-cursors-from-editor"; export class ObsidianFileSystemOperations implements FileSystemOperations { public constructor( diff --git a/frontend/obsidian-plugin/src/vault-link-plugin.ts b/frontend/obsidian-plugin/src/vault-link-plugin.ts index efd54417..9b9d62ab 100644 --- a/frontend/obsidian-plugin/src/vault-link-plugin.ts +++ b/frontend/obsidian-plugin/src/vault-link-plugin.ts @@ -1,27 +1,36 @@ import type { Editor, + EventRef, MarkdownFileInfo, - MarkdownView, TAbstractFile, + Workspace, WorkspaceLeaf } from "obsidian"; +import { MarkdownView } from "obsidian"; import { Platform, Plugin, TFile } from "obsidian"; import "../manifest.json"; import { HistoryView } from "./views/history/history-view"; import { StatusBar } from "./views/status-bar/status-bar"; import { LogsView } from "./views/logs/logs-view"; import { StatusDescription } from "./views/status-description/status-description"; +import type { CursorSpan, RelativePath } from "sync-client"; import { SyncClient, rateLimit, DEFAULT_SETTINGS } from "sync-client"; import { ObsidianFileSystemOperations } from "./obsidian-file-system"; import { SyncSettingsTab } from "./views/settings/settings-tab"; import { registerConsoleForLogging } from "./utils/register-console-for-logging"; import { updateEditorStatusDisplay } from "./views/editor-sync-line/editor-sync-line"; -import { remoteCursorsTheme } from "./views/remote-cursors/remote-cursor-theme"; -import { remoteCursorsPlugin } from "./views/remote-cursors/remote-cursors-plugin"; +import { remoteCursorsTheme } from "./views/cursors/remote-cursor-theme"; +import { + remoteCursorsPlugin, + setCursors +} from "./views/cursors/remote-cursors-plugin"; +import { getCursorsFromEditor } from "./views/cursors/get-cursors-from-editor"; +import { LocalCursorUpdateListener } from "./views/cursors/local-cursor-update-listener"; const MIN_WAIT_BETWEEN_UPDATES_IN_MS = 250; export default class VaultLinkPlugin extends Plugin { private readonly disposables: (() => unknown)[] = []; + private settingsTab: SyncSettingsTab | undefined; private client!: SyncClient; private readonly rateLimitedUpdatesPerFile = new Map< @@ -73,6 +82,17 @@ export default class VaultLinkPlugin extends Plugin { ); this.registerEditorExtension([remoteCursorsTheme, remoteCursorsPlugin]); + + this.client.addRemoteCursorsUpdateListener((cursors) => { + setCursors(cursors, this.app); + }); + + const cursorListener = new LocalCursorUpdateListener( + this.client, + this.app.workspace + ); + this.disposables.push(() => cursorListener.dispose()); + this.app.workspace.updateOptions(); this.addRibbonIcon( @@ -175,9 +195,7 @@ export default class VaultLinkPlugin extends Plugin { } } ) - ].forEach((event) => { - this.registerEvent(event); - }); + ].forEach((event) => this.registerEvent(event)); } private async rateLimitedUpdate(path: string): Promise { diff --git a/frontend/obsidian-plugin/src/utils/get-cursors-from-editor.ts b/frontend/obsidian-plugin/src/views/cursors/get-cursors-from-editor.ts similarity index 83% rename from frontend/obsidian-plugin/src/utils/get-cursors-from-editor.ts rename to frontend/obsidian-plugin/src/views/cursors/get-cursors-from-editor.ts index 62113d1b..f5ea0a85 100644 --- a/frontend/obsidian-plugin/src/utils/get-cursors-from-editor.ts +++ b/frontend/obsidian-plugin/src/views/cursors/get-cursors-from-editor.ts @@ -1,5 +1,5 @@ import type { Editor } from "obsidian"; -import { lineAndColumnToPosition } from "./line-and-column-to-position"; +import { lineAndColumnToPosition } from "../../utils/line-and-column-to-position"; export interface Cursor { id: number; diff --git a/frontend/obsidian-plugin/src/views/cursors/local-cursor-update-listener.ts b/frontend/obsidian-plugin/src/views/cursors/local-cursor-update-listener.ts new file mode 100644 index 00000000..319ae285 --- /dev/null +++ b/frontend/obsidian-plugin/src/views/cursors/local-cursor-update-listener.ts @@ -0,0 +1,57 @@ +import { + EventRef, + Workspace, + Editor, + MarkdownView, + MarkdownFileInfo +} from "obsidian"; +import { SyncClient } from "sync-client"; +import { Cursor, getCursorsFromEditor } from "./get-cursors-from-editor"; + +export class LocalCursorUpdateListener { + private static readonly UPDATE_INTERVAL_MS = 50; + private readonly eventHandle: NodeJS.Timeout; + private lastCursorState: Record = {}; + + public constructor( + private readonly client: SyncClient, + private readonly workspace: Workspace + ) { + this.eventHandle = setInterval( + () => this.updateAllCursors(), + LocalCursorUpdateListener.UPDATE_INTERVAL_MS + ); + } + + private updateAllCursors(): void { + const currentCursors = this.getAllCursors(); + if ( + JSON.stringify(this.lastCursorState) === + JSON.stringify(currentCursors) + ) { + return; + } + this.lastCursorState = currentCursors; + this.client.updateLocalCursors(currentCursors); + } + + private getAllCursors(): Record { + const cursors: Record = {}; + this.workspace + .getLeavesOfType("markdown") + .map((leaf) => leaf.view) + .filter((view) => view instanceof MarkdownView) + .forEach((view) => { + const { file } = view; + if (!file) { + return; + } + cursors[file.path] = getCursorsFromEditor(view.editor); + }); + return cursors; + } + + public dispose(): void { + clearInterval(this.eventHandle); + } +} -- 2.47.2 From deca4c4dc2cc010ac9710a8ee99c75980191ae6e Mon Sep 17 00:00:00 2001 From: Andras Schmelczer Date: Sun, 8 Jun 2025 12:08:20 +0100 Subject: [PATCH 26/29] Refine cursor look --- .../src/utils/get-random-color.ts | 2 +- .../src/views/cursors/remote-cursor-theme.ts | 63 +++++++++ .../remote-cursor-widget.ts | 17 ++- .../views/cursors/remote-cursors-plugin.ts | 129 ++++++++++++++++++ .../remote-cursors/remote-cursor-theme.ts | 67 --------- .../remote-cursors/remote-cursors-plugin.ts | 110 --------------- 6 files changed, 201 insertions(+), 187 deletions(-) create mode 100644 frontend/obsidian-plugin/src/views/cursors/remote-cursor-theme.ts rename frontend/obsidian-plugin/src/views/{remote-cursors => cursors}/remote-cursor-widget.ts (70%) create mode 100644 frontend/obsidian-plugin/src/views/cursors/remote-cursors-plugin.ts delete mode 100644 frontend/obsidian-plugin/src/views/remote-cursors/remote-cursor-theme.ts delete mode 100644 frontend/obsidian-plugin/src/views/remote-cursors/remote-cursors-plugin.ts diff --git a/frontend/obsidian-plugin/src/utils/get-random-color.ts b/frontend/obsidian-plugin/src/utils/get-random-color.ts index eadd0927..5b2d33dc 100644 --- a/frontend/obsidian-plugin/src/utils/get-random-color.ts +++ b/frontend/obsidian-plugin/src/utils/get-random-color.ts @@ -5,5 +5,5 @@ export function getRandomColor(name: string): string { hash |= 0; // Convert to 32bit integer } const normalised = hash / 0x7fffffff; - return `hsl(${Math.abs(normalised * 360)}, 70%, 30%)`; // HSL color + return `hsl(${Math.abs(normalised * 360)}, 55%, 55%)`; // HSL color } diff --git a/frontend/obsidian-plugin/src/views/cursors/remote-cursor-theme.ts b/frontend/obsidian-plugin/src/views/cursors/remote-cursor-theme.ts new file mode 100644 index 00000000..3af2692d --- /dev/null +++ b/frontend/obsidian-plugin/src/views/cursors/remote-cursor-theme.ts @@ -0,0 +1,63 @@ +import { EditorView } from "@codemirror/view"; + +const CARET_WIDTH = 2; +const DOT_RADIUS = 4; + +export const remoteCursorsTheme = EditorView.baseTheme({ + ".selection-caret": { + position: "relative" + }, + + ".selection-caret > *": { + position: "absolute", + backgroundColor: "inherit" + }, + + ".selection-caret > .stick": { + left: 0, + top: 0, + transform: "translateX(-50%)", + width: `${CARET_WIDTH}px`, + height: "100%", + display: "block", + borderRadius: `${CARET_WIDTH / 2}px`, + animation: "blink-stick 1s steps(1) infinite" + }, + + "@keyframes blink-stick": { + "0%, 100%": { opacity: 1 }, + "50%": { opacity: 0 } + }, + + ".selection-caret > .dot": { + borderRadius: "50%", + width: `${DOT_RADIUS * 2}px`, + height: `${DOT_RADIUS * 2}px`, + top: `-${DOT_RADIUS}px`, + left: `-${DOT_RADIUS}px`, + transition: "transform .3s ease-in-out", + transformOrigin: "bottom center", + boxSizing: "border-box" + }, + + ".selection-caret:hover > .dot": { + transform: "scale(0)" + }, + + ".selection-caret > .info": { + top: "-1.3em", + left: `-${CARET_WIDTH / 2}px`, + fontSize: "0.9em", + userSelect: "none", + color: "white", + padding: "0 2px", + transition: "opacity .3s ease-in-out", + opacity: 0, + whiteSpace: "nowrap", + borderRadius: "3px 3px 3px 0" + }, + + ".selection-caret:hover > .info": { + opacity: 1 + } +}); diff --git a/frontend/obsidian-plugin/src/views/remote-cursors/remote-cursor-widget.ts b/frontend/obsidian-plugin/src/views/cursors/remote-cursor-widget.ts similarity index 70% rename from frontend/obsidian-plugin/src/views/remote-cursors/remote-cursor-widget.ts rename to frontend/obsidian-plugin/src/views/cursors/remote-cursor-widget.ts index 767311ea..e3273484 100644 --- a/frontend/obsidian-plugin/src/views/remote-cursors/remote-cursor-widget.ts +++ b/frontend/obsidian-plugin/src/views/cursors/remote-cursor-widget.ts @@ -1,13 +1,12 @@ import { AnnotationType, Annotation, RangeSet, Range } from "@codemirror/state"; import { - EditorView, ViewUpdate, ViewPlugin, Decoration, WidgetType } from "@codemirror/view"; -import type { PluginValue, DecorationSet } from "@codemirror/view"; +import type { PluginValue, DecorationSet, EditorView } from "@codemirror/view"; export class RemoteCursorWidget extends WidgetType { public constructor( @@ -21,27 +20,27 @@ export class RemoteCursorWidget extends WidgetType { return editor.contentDOM.createEl( "span", { - cls: "SelectionCaret", + cls: "selection-caret", attr: { style: `background-color: ${this.color}; border-color: ${this.color}` } }, (span) => { - span.appendText("\u2060"); span.createEl("div", { - cls: "SelectionCaretDot" + cls: "stick" }); - span.appendText("\u2060"); span.createEl("div", { - cls: "SelectionInfo", + cls: "dot" + }); + span.createEl("div", { + cls: "info", text: this.name }); - span.appendText("\u2060"); } ); } - public eq(other: RemoteCursorWidget) { + public eq(other: RemoteCursorWidget): boolean { return other.color === this.color && other.name === this.name; } } diff --git a/frontend/obsidian-plugin/src/views/cursors/remote-cursors-plugin.ts b/frontend/obsidian-plugin/src/views/cursors/remote-cursors-plugin.ts new file mode 100644 index 00000000..2142fdff --- /dev/null +++ b/frontend/obsidian-plugin/src/views/cursors/remote-cursors-plugin.ts @@ -0,0 +1,129 @@ +import type { Range } from "@codemirror/state"; +import { RangeSet, Annotation, AnnotationType } from "@codemirror/state"; +import { ViewPlugin, Decoration, WidgetType } from "@codemirror/view"; + +import type { + PluginValue, + DecorationSet, + EditorView, + ViewUpdate +} from "@codemirror/view"; +import { RemoteCursorWidget } from "./remote-cursor-widget"; +import type { ClientCursors, CursorSpan } from "sync-client"; +import type { App } from "obsidian"; +import { MarkdownView } from "obsidian"; + +let cursors: { + name: string; + path: string; + span: CursorSpan; +}[] = []; + +import { StateEffect } from "@codemirror/state"; +import { getRandomColor } from "src/utils/get-random-color"; + +const forceUpdate = StateEffect.define(); + +export class RemoteCursorsPluginValue implements PluginValue { + public decorations: DecorationSet = RangeSet.of([]); + + public update(update: ViewUpdate): void { + const decorations: Range[] = []; + + cursors.forEach(({ name, span: { start, end } }) => { + const color = getRandomColor(name); + const startLine = update.view.state.doc.lineAt(start); + const endLine = update.view.state.doc.lineAt(end); + + const attributes = { + style: `background-color: ${color};` + }; + + if (startLine.number === endLine.number) { + // selected content in a single line. + decorations.push({ + from: start, + to: end, + value: Decoration.mark({ + attributes + }) + }); + } else { + // selected content in multiple lines + // first, render text-selection in the first line + decorations.push({ + from: start, + to: startLine.from + startLine.length, + value: Decoration.mark({ + attributes + }) + }); + + // render text-selection in the lines between the first and last line + for (let i = startLine.number + 1; i < endLine.number; i++) { + const currentLine = update.view.state.doc.line(i); + decorations.push({ + from: currentLine.from, + to: currentLine.to, + value: Decoration.mark({ + attributes + }) + }); + } + + // render text-selection in the last line + decorations.push({ + from: endLine.from, + to: end, + value: Decoration.mark({ + attributes + }) + }); + } + + decorations.push({ + from: end, + to: end, + value: Decoration.widget({ + side: end - start > 0 ? -1 : 1, // the local cursor should be rendered outside the remote selection + block: false, + widget: new RemoteCursorWidget(color, name) + }) + }); + }); + + this.decorations = Decoration.set(decorations, true); + } +} + +export const remoteCursorsPlugin = ViewPlugin.fromClass( + RemoteCursorsPluginValue, + { + decorations: (v) => v.decorations + } +); + +export function setCursors(clients: ClientCursors[], app: App) { + cursors = clients.flatMap((client) => { + return Object.keys(client.cursors).flatMap((path) => + client.cursors[path]!.map((span) => ({ + name: client.userName, + path, + span + })) + ); + }); + + app.workspace + .getLeavesOfType("markdown") + .map((leaf) => leaf.view) + .filter((view) => view instanceof MarkdownView) + .forEach((view) => { + // @ts-expect-error, not typed + const editor = view.editor.cm as EditorView; + + editor.dispatch({ + effects: [forceUpdate.of(null)] + }); + }); +} diff --git a/frontend/obsidian-plugin/src/views/remote-cursors/remote-cursor-theme.ts b/frontend/obsidian-plugin/src/views/remote-cursors/remote-cursor-theme.ts deleted file mode 100644 index 373ec9f1..00000000 --- a/frontend/obsidian-plugin/src/views/remote-cursors/remote-cursor-theme.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { AnnotationType, Annotation, RangeSet, Range } from "@codemirror/state"; -import { - EditorView, - ViewUpdate, - ViewPlugin, - Decoration, - WidgetType -} from "@codemirror/view"; - -import type { PluginValue, DecorationSet } from "@codemirror/view"; - -export const remoteCursorsTheme = EditorView.baseTheme({ - ".Selection": {}, - ".LineSelection": { - padding: 0, - margin: "0px 2px 0px 4px" - }, - ".SelectionCaret": { - position: "relative", - borderLeft: "1px solid black", - borderRight: "1px solid black", - marginLeft: "-1px", - marginRight: "-1px", - boxSizing: "border-box", - display: "inline" - }, - ".SelectionCaretDot": { - borderRadius: "50%", - position: "absolute", - width: ".4em", - height: ".4em", - top: "-.2em", - left: "-.2em", - backgroundColor: "inherit", - transition: "transform .3s ease-in-out", - boxSizing: "border-box" - }, - ".SelectionCaret:hover > .SelectionCaretDot": { - transformOrigin: "bottom center", - transform: "scale(0)" - }, - ".SelectionInfo": { - position: "absolute", - top: "-1.05em", - left: "-1px", - fontSize: ".75em", - fontFamily: "serif", - fontStyle: "normal", - fontWeight: "normal", - lineHeight: "normal", - userSelect: "none", - color: "white", - paddingLeft: "2px", - paddingRight: "2px", - zIndex: 101, - transition: "opacity .3s ease-in-out", - backgroundColor: "inherit", - // these should be separate - opacity: 0, - transitionDelay: "0s", - whiteSpace: "nowrap" - }, - ".SelectionCaret:hover > .SelectionInfo": { - opacity: 1, - transitionDelay: "0s" - } -}); diff --git a/frontend/obsidian-plugin/src/views/remote-cursors/remote-cursors-plugin.ts b/frontend/obsidian-plugin/src/views/remote-cursors/remote-cursors-plugin.ts deleted file mode 100644 index 52e2d5e0..00000000 --- a/frontend/obsidian-plugin/src/views/remote-cursors/remote-cursors-plugin.ts +++ /dev/null @@ -1,110 +0,0 @@ -import { RangeSet, Range } from "@codemirror/state"; -import { - EditorView, - ViewUpdate, - ViewPlugin, - Decoration, - WidgetType -} from "@codemirror/view"; - -import type { PluginValue, DecorationSet } from "@codemirror/view"; -import { RemoteCursorWidget } from "./remote-cursor-widget"; - -export class RemoteCursorsPluginValue implements PluginValue { - public decorations: DecorationSet = RangeSet.of([]); - - public constructor(private readonly _editor: EditorView) {} - - public update(update: ViewUpdate) { - const decorations: Array> = []; - - const cursors: { - name: string; - color: string; - anchor: { index: number }; - head: { index: number }; - }[] = [ - { - name: "Alice", - color: "#ff6b6b", - anchor: { index: 10 }, - head: { index: 20 } - } - ]; - - cursors.forEach(({ name, color, anchor, head }) => { - const start = Math.min(anchor.index, head.index); - const end = Math.max(anchor.index, head.index); - const startLine = update.view.state.doc.lineAt(start); - const endLine = update.view.state.doc.lineAt(end); - - if (startLine.number === endLine.number) { - // selected content in a single line. - decorations.push({ - from: start, - to: end, - value: Decoration.mark({ - attributes: { - style: `background-color: ${color}` - }, - class: "Selection" - }) - }); - } else { - // selected content in multiple lines - // first, render text-selection in the first line - decorations.push({ - from: start, - to: startLine.from + startLine.length, - value: Decoration.mark({ - attributes: { - style: `background-color: ${color}` - }, - class: "Selection" - }) - }); - // render text-selection in the last line - decorations.push({ - from: endLine.from, - to: end, - value: Decoration.mark({ - attributes: { - style: `background-color: ${color}` - }, - class: "Selection" - }) - }); - for (let i = startLine.number + 1; i < endLine.number; i++) { - const linePos = update.view.state.doc.line(i).from; - decorations.push({ - from: linePos, - to: linePos, - value: Decoration.line({ - attributes: { - style: `background-color: ${color}`, - class: "LineSelection" - } - }) - }); - } - } - decorations.push({ - from: head.index, - to: head.index, - value: Decoration.widget({ - side: head.index - anchor.index > 0 ? -1 : 1, // the local cursor should be rendered outside the remote selection - block: false, - widget: new RemoteCursorWidget(color, name) - }) - }); - }); - this.decorations = Decoration.set(decorations, true); - } -} - -export const remoteCursorsPlugin = ViewPlugin.fromClass( - RemoteCursorsPluginValue, - { - decorations: (v) => v.decorations - } -); -- 2.47.2 From 83823b48f2fc2b390d03f19ec40ccad99ba06873 Mon Sep 17 00:00:00 2001 From: Andras Schmelczer Date: Sun, 8 Jun 2025 12:13:35 +0100 Subject: [PATCH 27/29] Format and lint --- .../obsidian-plugin/src/vault-link-plugin.ts | 10 +++-- .../cursors/local-cursor-update-listener.ts | 37 ++++++++++--------- .../views/cursors/remote-cursors-plugin.ts | 21 +++++++---- .../src/services/types/ClientCursors.ts | 6 ++- .../services/types/CreateDocumentVersion.ts | 19 ++++++---- .../types/CursorPositionFromClient.ts | 4 +- .../types/CursorPositionFromServer.ts | 4 +- .../src/services/types/CursorSpan.ts | 5 ++- .../services/types/DeleteDocumentVersion.ts | 4 +- .../services/types/DocumentUpdateResponse.ts | 4 +- .../src/services/types/DocumentVersion.ts | 11 +++++- .../types/DocumentVersionWithoutContent.ts | 11 +++++- .../types/FetchLatestDocumentsResponse.ts | 12 +++--- .../src/services/types/PingResponse.ts | 21 ++++++----- .../src/services/types/SerializedError.ts | 6 ++- .../services/types/UpdateDocumentVersion.ts | 6 ++- .../services/types/WebSocketClientMessage.ts | 4 +- .../src/services/types/WebSocketHandshake.ts | 6 ++- .../services/types/WebSocketServerMessage.ts | 4 +- .../services/types/WebSocketVaultUpdate.ts | 5 ++- frontend/sync-client/src/sync-client.ts | 4 +- 21 files changed, 137 insertions(+), 67 deletions(-) diff --git a/frontend/obsidian-plugin/src/vault-link-plugin.ts b/frontend/obsidian-plugin/src/vault-link-plugin.ts index 9b9d62ab..315e2d19 100644 --- a/frontend/obsidian-plugin/src/vault-link-plugin.ts +++ b/frontend/obsidian-plugin/src/vault-link-plugin.ts @@ -6,7 +6,7 @@ import type { Workspace, WorkspaceLeaf } from "obsidian"; -import { MarkdownView } from "obsidian"; +import type { MarkdownView } from "obsidian"; import { Platform, Plugin, TFile } from "obsidian"; import "../manifest.json"; import { HistoryView } from "./views/history/history-view"; @@ -91,7 +91,9 @@ export default class VaultLinkPlugin extends Plugin { this.client, this.app.workspace ); - this.disposables.push(() => cursorListener.dispose()); + this.disposables.push(() => { + cursorListener.dispose(); + }); this.app.workspace.updateOptions(); @@ -195,7 +197,9 @@ export default class VaultLinkPlugin extends Plugin { } } ) - ].forEach((event) => this.registerEvent(event)); + ].forEach((event) => { + this.registerEvent(event); + }); } private async rateLimitedUpdate(path: string): Promise { diff --git a/frontend/obsidian-plugin/src/views/cursors/local-cursor-update-listener.ts b/frontend/obsidian-plugin/src/views/cursors/local-cursor-update-listener.ts index 319ae285..99a9828d 100644 --- a/frontend/obsidian-plugin/src/views/cursors/local-cursor-update-listener.ts +++ b/frontend/obsidian-plugin/src/views/cursors/local-cursor-update-listener.ts @@ -1,12 +1,8 @@ -import { - EventRef, - Workspace, - Editor, - MarkdownView, - MarkdownFileInfo -} from "obsidian"; -import { SyncClient } from "sync-client"; -import { Cursor, getCursorsFromEditor } from "./get-cursors-from-editor"; +import type { Workspace } from "obsidian"; +import { EventRef, Editor, MarkdownView, MarkdownFileInfo } from "obsidian"; +import type { Logger, SyncClient } from "sync-client"; +import type { Cursor } from "./get-cursors-from-editor"; +import { getCursorsFromEditor } from "./get-cursors-from-editor"; export class LocalCursorUpdateListener { private static readonly UPDATE_INTERVAL_MS = 50; @@ -17,10 +13,13 @@ export class LocalCursorUpdateListener { private readonly client: SyncClient, private readonly workspace: Workspace ) { - this.eventHandle = setInterval( - () => this.updateAllCursors(), - LocalCursorUpdateListener.UPDATE_INTERVAL_MS - ); + this.eventHandle = setInterval(() => { + this.updateAllCursors(); + }, LocalCursorUpdateListener.UPDATE_INTERVAL_MS); + } + + public dispose(): void { + clearInterval(this.eventHandle); } private updateAllCursors(): void { @@ -32,7 +31,13 @@ export class LocalCursorUpdateListener { return; } this.lastCursorState = currentCursors; - this.client.updateLocalCursors(currentCursors); + this.client + .updateLocalCursors(currentCursors) + .catch((error: unknown) => { + this.client.logger.error( + `Failed to update local cursors: ${error}` + ); + }); } private getAllCursors(): Record { @@ -50,8 +55,4 @@ export class LocalCursorUpdateListener { }); return cursors; } - - public dispose(): void { - clearInterval(this.eventHandle); - } } diff --git a/frontend/obsidian-plugin/src/views/cursors/remote-cursors-plugin.ts b/frontend/obsidian-plugin/src/views/cursors/remote-cursors-plugin.ts index 2142fdff..e7797d1a 100644 --- a/frontend/obsidian-plugin/src/views/cursors/remote-cursors-plugin.ts +++ b/frontend/obsidian-plugin/src/views/cursors/remote-cursors-plugin.ts @@ -103,15 +103,19 @@ export const remoteCursorsPlugin = ViewPlugin.fromClass( } ); -export function setCursors(clients: ClientCursors[], app: App) { +export function setCursors(clients: ClientCursors[], app: App): void { cursors = clients.flatMap((client) => { - return Object.keys(client.cursors).flatMap((path) => - client.cursors[path]!.map((span) => ({ - name: client.userName, - path, - span - })) - ); + const clientCursors = client.cursors; + return Object.keys(clientCursors).flatMap((path) => { + const spans = clientCursors[path]; + return spans + ? spans.map((span) => ({ + name: client.userName, + path, + span + })) + : []; + }); }); app.workspace @@ -120,6 +124,7 @@ export function setCursors(clients: ClientCursors[], app: App) { .filter((view) => view instanceof MarkdownView) .forEach((view) => { // @ts-expect-error, not typed + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion const editor = view.editor.cm as EditorView; editor.dispatch({ diff --git a/frontend/sync-client/src/services/types/ClientCursors.ts b/frontend/sync-client/src/services/types/ClientCursors.ts index cec4f064..9bf8739f 100644 --- a/frontend/sync-client/src/services/types/ClientCursors.ts +++ b/frontend/sync-client/src/services/types/ClientCursors.ts @@ -1,4 +1,8 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. import type { CursorSpan } from "./CursorSpan"; -export interface ClientCursors { userName: string, deviceId: string, cursors: Partial>, } +export interface ClientCursors { + userName: string; + deviceId: string; + cursors: Partial>; +} diff --git a/frontend/sync-client/src/services/types/CreateDocumentVersion.ts b/frontend/sync-client/src/services/types/CreateDocumentVersion.ts index 2a5ea1a0..d4bd376b 100644 --- a/frontend/sync-client/src/services/types/CreateDocumentVersion.ts +++ b/frontend/sync-client/src/services/types/CreateDocumentVersion.ts @@ -1,10 +1,13 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -export interface CreateDocumentVersion { -/** - * The client can decide the document id (if it wishes to) in order - * to help with syncing. If the client does not provide a document id, - * the server will generate one. If the client provides a document id - * it must not already exist in the database. - */ -document_id: string | null, relative_path: string, content: number[], } +export interface CreateDocumentVersion { + /** + * The client can decide the document id (if it wishes to) in order + * to help with syncing. If the client does not provide a document id, + * the server will generate one. If the client provides a document id + * it must not already exist in the database. + */ + document_id: string | null; + relative_path: string; + content: number[]; +} diff --git a/frontend/sync-client/src/services/types/CursorPositionFromClient.ts b/frontend/sync-client/src/services/types/CursorPositionFromClient.ts index f51b6603..d33c0c8e 100644 --- a/frontend/sync-client/src/services/types/CursorPositionFromClient.ts +++ b/frontend/sync-client/src/services/types/CursorPositionFromClient.ts @@ -1,4 +1,6 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. import type { CursorSpan } from "./CursorSpan"; -export interface CursorPositionFromClient { documentToCursors: Partial>, } +export interface CursorPositionFromClient { + documentToCursors: Partial>; +} diff --git a/frontend/sync-client/src/services/types/CursorPositionFromServer.ts b/frontend/sync-client/src/services/types/CursorPositionFromServer.ts index ed6ac7b2..2556b748 100644 --- a/frontend/sync-client/src/services/types/CursorPositionFromServer.ts +++ b/frontend/sync-client/src/services/types/CursorPositionFromServer.ts @@ -1,4 +1,6 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. import type { ClientCursors } from "./ClientCursors"; -export interface CursorPositionFromServer { clients: ClientCursors[], } +export interface CursorPositionFromServer { + clients: ClientCursors[]; +} diff --git a/frontend/sync-client/src/services/types/CursorSpan.ts b/frontend/sync-client/src/services/types/CursorSpan.ts index 7424067c..5bc2542e 100644 --- a/frontend/sync-client/src/services/types/CursorSpan.ts +++ b/frontend/sync-client/src/services/types/CursorSpan.ts @@ -1,3 +1,6 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -export interface CursorSpan { start: number, end: number, } +export interface CursorSpan { + start: number; + end: number; +} diff --git a/frontend/sync-client/src/services/types/DeleteDocumentVersion.ts b/frontend/sync-client/src/services/types/DeleteDocumentVersion.ts index 5d4bad98..9edb09ed 100644 --- a/frontend/sync-client/src/services/types/DeleteDocumentVersion.ts +++ b/frontend/sync-client/src/services/types/DeleteDocumentVersion.ts @@ -1,3 +1,5 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -export interface DeleteDocumentVersion { relativePath: string, } +export interface DeleteDocumentVersion { + relativePath: string; +} diff --git a/frontend/sync-client/src/services/types/DocumentUpdateResponse.ts b/frontend/sync-client/src/services/types/DocumentUpdateResponse.ts index 418117e6..f0ed7abf 100644 --- a/frontend/sync-client/src/services/types/DocumentUpdateResponse.ts +++ b/frontend/sync-client/src/services/types/DocumentUpdateResponse.ts @@ -5,4 +5,6 @@ import type { DocumentVersionWithoutContent } from "./DocumentVersionWithoutCont /** * Response to an update document request. */ -export type DocumentUpdateResponse = { "type": "FastForwardUpdate" } & DocumentVersionWithoutContent | { "type": "MergingUpdate" } & DocumentVersion; +export type DocumentUpdateResponse = + | ({ type: "FastForwardUpdate" } & DocumentVersionWithoutContent) + | ({ type: "MergingUpdate" } & DocumentVersion); diff --git a/frontend/sync-client/src/services/types/DocumentVersion.ts b/frontend/sync-client/src/services/types/DocumentVersion.ts index 3d50ae65..2076d296 100644 --- a/frontend/sync-client/src/services/types/DocumentVersion.ts +++ b/frontend/sync-client/src/services/types/DocumentVersion.ts @@ -1,3 +1,12 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -export interface DocumentVersion { vaultUpdateId: number, documentId: string, relativePath: string, updatedDate: string, contentBase64: string, isDeleted: boolean, userId: string, deviceId: string, } +export interface DocumentVersion { + vaultUpdateId: number; + documentId: string; + relativePath: string; + updatedDate: string; + contentBase64: string; + isDeleted: boolean; + userId: string; + deviceId: string; +} diff --git a/frontend/sync-client/src/services/types/DocumentVersionWithoutContent.ts b/frontend/sync-client/src/services/types/DocumentVersionWithoutContent.ts index af064db8..cb23f6a5 100644 --- a/frontend/sync-client/src/services/types/DocumentVersionWithoutContent.ts +++ b/frontend/sync-client/src/services/types/DocumentVersionWithoutContent.ts @@ -1,3 +1,12 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -export interface DocumentVersionWithoutContent { vaultUpdateId: number, documentId: string, relativePath: string, updatedDate: string, isDeleted: boolean, userId: string, deviceId: string, contentSize: number, } +export interface DocumentVersionWithoutContent { + vaultUpdateId: number; + documentId: string; + relativePath: string; + updatedDate: string; + isDeleted: boolean; + userId: string; + deviceId: string; + contentSize: number; +} diff --git a/frontend/sync-client/src/services/types/FetchLatestDocumentsResponse.ts b/frontend/sync-client/src/services/types/FetchLatestDocumentsResponse.ts index 3be625bd..67c19b2d 100644 --- a/frontend/sync-client/src/services/types/FetchLatestDocumentsResponse.ts +++ b/frontend/sync-client/src/services/types/FetchLatestDocumentsResponse.ts @@ -4,8 +4,10 @@ import type { DocumentVersionWithoutContent } from "./DocumentVersionWithoutCont /** * Response to a fetch latest documents request. */ -export interface FetchLatestDocumentsResponse { latestDocuments: DocumentVersionWithoutContent[], -/** - * The update ID of the latest document in the response. - */ -lastUpdateId: bigint, } +export interface FetchLatestDocumentsResponse { + latestDocuments: DocumentVersionWithoutContent[]; + /** + * The update ID of the latest document in the response. + */ + lastUpdateId: bigint; +} diff --git a/frontend/sync-client/src/services/types/PingResponse.ts b/frontend/sync-client/src/services/types/PingResponse.ts index 7d64ea36..b0d993f2 100644 --- a/frontend/sync-client/src/services/types/PingResponse.ts +++ b/frontend/sync-client/src/services/types/PingResponse.ts @@ -3,13 +3,14 @@ /** * Response to a ping request. */ -export interface PingResponse { -/** - * Semantic version of the server. - */ -serverVersion: string, -/** - * Whether the client is authenticated based on the sent Authorization - * header. - */ -isAuthenticated: boolean, } +export interface PingResponse { + /** + * Semantic version of the server. + */ + serverVersion: string; + /** + * Whether the client is authenticated based on the sent Authorization + * header. + */ + isAuthenticated: boolean; +} diff --git a/frontend/sync-client/src/services/types/SerializedError.ts b/frontend/sync-client/src/services/types/SerializedError.ts index 4389289e..c0979c5a 100644 --- a/frontend/sync-client/src/services/types/SerializedError.ts +++ b/frontend/sync-client/src/services/types/SerializedError.ts @@ -1,3 +1,7 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -export interface SerializedError { errorType: string, message: string, causes: string[], } +export interface SerializedError { + errorType: string; + message: string; + causes: string[]; +} diff --git a/frontend/sync-client/src/services/types/UpdateDocumentVersion.ts b/frontend/sync-client/src/services/types/UpdateDocumentVersion.ts index e0ddd5ac..bc3d54e5 100644 --- a/frontend/sync-client/src/services/types/UpdateDocumentVersion.ts +++ b/frontend/sync-client/src/services/types/UpdateDocumentVersion.ts @@ -1,3 +1,7 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -export interface UpdateDocumentVersion { parent_version_id: bigint, relative_path: string, content: number[], } +export interface UpdateDocumentVersion { + parent_version_id: bigint; + relative_path: string; + content: number[]; +} diff --git a/frontend/sync-client/src/services/types/WebSocketClientMessage.ts b/frontend/sync-client/src/services/types/WebSocketClientMessage.ts index 5765a0d0..e7de2cf3 100644 --- a/frontend/sync-client/src/services/types/WebSocketClientMessage.ts +++ b/frontend/sync-client/src/services/types/WebSocketClientMessage.ts @@ -2,4 +2,6 @@ import type { CursorPositionFromClient } from "./CursorPositionFromClient"; import type { WebSocketHandshake } from "./WebSocketHandshake"; -export type WebSocketClientMessage = { "type": "handshake" } & WebSocketHandshake | { "type": "cursorPositions" } & CursorPositionFromClient; +export type WebSocketClientMessage = + | ({ type: "handshake" } & WebSocketHandshake) + | ({ type: "cursorPositions" } & CursorPositionFromClient); diff --git a/frontend/sync-client/src/services/types/WebSocketHandshake.ts b/frontend/sync-client/src/services/types/WebSocketHandshake.ts index d25651f9..068b3505 100644 --- a/frontend/sync-client/src/services/types/WebSocketHandshake.ts +++ b/frontend/sync-client/src/services/types/WebSocketHandshake.ts @@ -1,3 +1,7 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -export interface WebSocketHandshake { token: string, deviceId: string, lastSeenVaultUpdateId: number | null, } +export interface WebSocketHandshake { + token: string; + deviceId: string; + lastSeenVaultUpdateId: number | null; +} diff --git a/frontend/sync-client/src/services/types/WebSocketServerMessage.ts b/frontend/sync-client/src/services/types/WebSocketServerMessage.ts index 45e37358..8ebf8911 100644 --- a/frontend/sync-client/src/services/types/WebSocketServerMessage.ts +++ b/frontend/sync-client/src/services/types/WebSocketServerMessage.ts @@ -2,4 +2,6 @@ import type { CursorPositionFromServer } from "./CursorPositionFromServer"; import type { WebSocketVaultUpdate } from "./WebSocketVaultUpdate"; -export type WebSocketServerMessage = { "type": "vaultUpdate" } & WebSocketVaultUpdate | { "type": "cursorPositions" } & CursorPositionFromServer; +export type WebSocketServerMessage = + | ({ type: "vaultUpdate" } & WebSocketVaultUpdate) + | ({ type: "cursorPositions" } & CursorPositionFromServer); diff --git a/frontend/sync-client/src/services/types/WebSocketVaultUpdate.ts b/frontend/sync-client/src/services/types/WebSocketVaultUpdate.ts index 39e03b6f..ad50c25d 100644 --- a/frontend/sync-client/src/services/types/WebSocketVaultUpdate.ts +++ b/frontend/sync-client/src/services/types/WebSocketVaultUpdate.ts @@ -1,4 +1,7 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. import type { DocumentVersionWithoutContent } from "./DocumentVersionWithoutContent"; -export interface WebSocketVaultUpdate { documents: DocumentVersionWithoutContent[], isInitialSync: boolean, } +export interface WebSocketVaultUpdate { + documents: DocumentVersionWithoutContent[]; + isInitialSync: boolean; +} diff --git a/frontend/sync-client/src/sync-client.ts b/frontend/sync-client/src/sync-client.ts index 70e100ed..6d51212e 100644 --- a/frontend/sync-client/src/sync-client.ts +++ b/frontend/sync-client/src/sync-client.ts @@ -275,7 +275,9 @@ export class SyncClient { }); } - public async updateLocalCursors(documentToCursors: Record): Promise { + public async updateLocalCursors( + documentToCursors: Record + ): Promise { this.webSocketManager.updateLocalCursors({ documentToCursors }); } -- 2.47.2 From 86e158c7c543f77c20ce5e8614bbe0c9fc9a58bd Mon Sep 17 00:00:00 2001 From: Andras Schmelczer Date: Sun, 8 Jun 2025 14:45:24 +0100 Subject: [PATCH 28/29] Add homepage --- backend/sync_server/src/server.rs | 2 ++ backend/sync_server/src/server/assets/index.html | 9 +++++++++ backend/sync_server/src/server/index.rs | 7 +++++++ 3 files changed, 18 insertions(+) create mode 100644 backend/sync_server/src/server/assets/index.html create mode 100644 backend/sync_server/src/server/index.rs diff --git a/backend/sync_server/src/server.rs b/backend/sync_server/src/server.rs index efc5f071..3f659c97 100644 --- a/backend/sync_server/src/server.rs +++ b/backend/sync_server/src/server.rs @@ -6,6 +6,7 @@ mod fetch_document_version; mod fetch_document_version_content; mod fetch_latest_document_version; mod fetch_latest_documents; +mod index; mod ping; mod requests; mod responses; @@ -54,6 +55,7 @@ pub async fn create_server(config_path: Option) -> Result<()> { let app = Router::new() .nest("/", get_authed_routes(app_state.clone())) + .route("/", get(index::index)) .route("/vaults/:vault_id/ping", get(ping::ping)) .route("/vaults/:vault_id/ws", get(websocket::websocket_handler)) .layer(DefaultBodyLimit::disable()) diff --git a/backend/sync_server/src/server/assets/index.html b/backend/sync_server/src/server/assets/index.html new file mode 100644 index 00000000..ef9c5a6d --- /dev/null +++ b/backend/sync_server/src/server/assets/index.html @@ -0,0 +1,9 @@ + + + + VaultLink + + +

VaultLink server

+ + diff --git a/backend/sync_server/src/server/index.rs b/backend/sync_server/src/server/index.rs new file mode 100644 index 00000000..64b053f7 --- /dev/null +++ b/backend/sync_server/src/server/index.rs @@ -0,0 +1,7 @@ +use axum::response::{Html, IntoResponse}; + +pub async fn index() -> impl IntoResponse { + const HTML_CONTENT: &str = include_str!("./assets/index.html"); + let html_content = HTML_CONTENT; + Html(html_content) +} -- 2.47.2 From b25bc4085157e05343566f93d36fd810b96a4835 Mon Sep 17 00:00:00 2001 From: Andras Schmelczer Date: Sun, 8 Jun 2025 20:20:13 +0100 Subject: [PATCH 29/29] Fix PR diff --- backend/config-e2e.yml | 1 - backend/sync_server/src/app_state/cursors.rs | 13 ++++--------- .../sync_server/src/config/database_config.rs | 16 +--------------- backend/sync_server/src/consts.rs | 1 - 4 files changed, 5 insertions(+), 26 deletions(-) diff --git a/backend/config-e2e.yml b/backend/config-e2e.yml index 5018c716..5f2346d6 100644 --- a/backend/config-e2e.yml +++ b/backend/config-e2e.yml @@ -2,7 +2,6 @@ database: databases_directory_path: databases max_connections_per_vault: 12 cursor_timeout_seconds: 60 - cursor_broadcast_interval_seconds: 1 server: host: 0.0.0.0 port: 3000 diff --git a/backend/sync_server/src/app_state/cursors.rs b/backend/sync_server/src/app_state/cursors.rs index 6b8a8605..245109c2 100644 --- a/backend/sync_server/src/app_state/cursors.rs +++ b/backend/sync_server/src/app_state/cursors.rs @@ -50,7 +50,6 @@ impl Cursors { })); drop(vault_to_cursors); // Explicitly drop the lock before broadcasting to avoid deadlock - self.broadcast_cursors().await; } @@ -70,17 +69,13 @@ impl Cursors { pub fn start_background_task(self) { tokio::spawn(async move { - self.run_backround_task().await; + loop { + self.remove_expired_cursors().await; + tokio::time::sleep(Duration::from_secs(1)).await; + } }); } - async fn run_backround_task(&self) { - loop { - self.remove_expired_cursors().await; - tokio::time::sleep(self.config.cursor_broadcast_interval).await; - } - } - async fn remove_expired_cursors(&self) { let mut vault_to_cursors = self.vault_to_cursors.lock().await; diff --git a/backend/sync_server/src/config/database_config.rs b/backend/sync_server/src/config/database_config.rs index 118d805e..f1c92d9d 100644 --- a/backend/sync_server/src/config/database_config.rs +++ b/backend/sync_server/src/config/database_config.rs @@ -5,8 +5,7 @@ use serde::{Deserialize, Serialize}; use serde_with::serde_as; use crate::consts::{ - DEFAULT_CURSOR_BROADCAST_INTERVAL, DEFAULT_CURSOR_TIMEOUT, DEFAULT_DATABASES_DIRECTORY_PATH, - DEFAULT_MAX_CONNECTIONS_PER_VAULT, + DEFAULT_CURSOR_TIMEOUT, DEFAULT_DATABASES_DIRECTORY_PATH, DEFAULT_MAX_CONNECTIONS_PER_VAULT, }; #[serde_with::serde_as] @@ -21,13 +20,6 @@ pub struct DatabaseConfig { #[serde(default = "default_cursor_timeout", rename = "cursor_timeout_seconds")] #[serde_as(as = "serde_with::DurationSeconds")] pub cursor_timeout: Duration, - - #[serde( - default = "default_cursor_broadcast_interval", - rename = "cursor_broadcast_interval_seconds" - )] - #[serde_as(as = "serde_with::DurationSeconds")] - pub cursor_broadcast_interval: Duration, } fn default_databases_directory_path() -> PathBuf { @@ -45,18 +37,12 @@ fn default_cursor_timeout() -> Duration { DEFAULT_CURSOR_TIMEOUT } -fn default_cursor_broadcast_interval() -> Duration { - debug!("Using default cursor broadcast interval: {DEFAULT_CURSOR_BROADCAST_INTERVAL:?}"); - DEFAULT_CURSOR_BROADCAST_INTERVAL -} - impl Default for DatabaseConfig { fn default() -> Self { Self { databases_directory_path: default_databases_directory_path(), max_connections_per_vault: default_max_connections_per_vault(), cursor_timeout: default_cursor_timeout(), - cursor_broadcast_interval: default_cursor_broadcast_interval(), } } } diff --git a/backend/sync_server/src/consts.rs b/backend/sync_server/src/consts.rs index 01927335..df5a2844 100644 --- a/backend/sync_server/src/consts.rs +++ b/backend/sync_server/src/consts.rs @@ -5,7 +5,6 @@ pub const DEFAULT_CONFIG_PATH: &str = "config.yml"; pub const DEFAULT_DATABASES_DIRECTORY_PATH: &str = "databases"; pub const DEFAULT_MAX_CONNECTIONS_PER_VAULT: u32 = 12; pub const DEFAULT_CURSOR_TIMEOUT: Duration = Duration::from_secs(60); -pub const DEFAULT_CURSOR_BROADCAST_INTERVAL: Duration = Duration::from_secs(1); pub const DEFAULT_HOST: &str = "127.0.0.1"; pub const DEFAULT_PORT: u16 = 3000; -- 2.47.2