From e8b9bf40c5b02778ce20e131e5e22fb8f22d2ae4 Mon Sep 17 00:00:00 2001 From: Andras Schmelczer Date: Sun, 8 Jun 2025 20:20:52 +0100 Subject: [PATCH] Add API for propagating cursor locations (#61) --- .gitignore | 35 +- backend/Cargo.lock | 381 +++------- backend/Cargo.toml | 1 - backend/config-e2e.yml | 33 +- .../operation_transformation/merge_context.rs | 9 +- backend/rust-toolchain.toml | 2 +- backend/sync_server/Cargo.toml | 10 +- backend/sync_server/src/app_state.rs | 13 +- backend/sync_server/src/app_state/cursors.rs | 128 ++++ backend/sync_server/src/app_state/database.rs | 28 +- .../src/app_state/database/models.rs | 13 +- .../sync_server/src/app_state/websocket.rs | 3 + .../app_state/{ => websocket}/broadcasts.rs | 36 +- .../src/app_state/websocket/models.rs | 88 +++ .../src/app_state/websocket/utils.rs | 80 +++ backend/sync_server/src/config.rs | 24 +- .../sync_server/src/config/database_config.rs | 18 +- backend/sync_server/src/consts.rs | 7 +- backend/sync_server/src/errors.rs | 38 +- backend/sync_server/src/server.rs | 91 +-- .../sync_server/src/server/assets/index.html | 9 + backend/sync_server/src/server/auth.rs | 11 +- .../sync_server/src/server/create_document.rs | 99 +-- .../sync_server/src/server/delete_document.rs | 23 +- .../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/index.rs | 7 + backend/sync_server/src/server/ping.rs | 4 +- backend/sync_server/src/server/requests.rs | 38 +- backend/sync_server/src/server/responses.rs | 11 +- .../sync_server/src/server/update_document.rs | 104 +-- backend/sync_server/src/server/websocket.rs | 221 +++--- frontend/obsidian-plugin/package.json | 76 +- .../src/obsidian-file-system.ts | 26 +- .../src/utils/get-random-color.ts | 9 + .../obsidian-plugin/src/vault-link-plugin.ts | 38 +- .../views/cursors/get-cursors-from-editor.ts | 17 + .../cursors/local-cursor-update-listener.ts | 58 ++ .../src/views/cursors/remote-cursor-theme.ts | 63 ++ .../src/views/cursors/remote-cursor-widget.ts | 46 ++ .../views/cursors/remote-cursors-plugin.ts | 134 ++++ .../src/views/history/history-view.ts | 4 +- .../status-description/status-description.ts | 2 +- frontend/obsidian-plugin/webpack.config.js | 11 +- frontend/package-lock.json | 484 ++++--------- frontend/package.json | 6 +- frontend/sync-client/package.json | 8 +- frontend/sync-client/src/index.ts | 3 +- .../sync-client/src/persistence/settings.ts | 4 +- .../src/services/connection-status.ts | 7 +- .../sync-client/src/services/sync-service.ts | 301 ++++---- frontend/sync-client/src/services/types.ts | 655 ------------------ .../src/services/types/ClientCursors.ts | 8 + .../services/types/CreateDocumentVersion.ts | 13 + .../types/CursorPositionFromClient.ts | 6 + .../types/CursorPositionFromServer.ts | 6 + .../src/services/types/CursorSpan.ts | 6 + .../services/types/DeleteDocumentVersion.ts | 5 + .../services/types/DocumentUpdateResponse.ts | 10 + .../src/services/types/DocumentVersion.ts | 12 + .../types/DocumentVersionWithoutContent.ts | 12 + .../types/FetchLatestDocumentsResponse.ts | 13 + .../src/services/types/PingResponse.ts | 16 + .../src/services/types/SerializedError.ts | 7 + .../services/types/UpdateDocumentVersion.ts | 7 + .../services/types/WebSocketClientMessage.ts | 7 + .../src/services/types/WebSocketHandshake.ts | 7 + .../services/types/WebSocketServerMessage.ts | 7 + .../services/types/WebSocketVaultUpdate.ts | 7 + .../src/services/websocket-manager.ts | 209 ++++++ frontend/sync-client/src/sync-client.ts | 44 +- .../sync-client/src/sync-operations/syncer.ts | 155 +---- .../sync-operations/unrestricted-syncer.ts | 12 +- .../sync-client/src/utils/create-client-id.ts | 15 + .../sync-client/src/utils/create-promise.ts | 4 + frontend/sync-client/src/utils/locks.ts | 2 +- frontend/test-client/package.json | 6 +- scripts/update-api-types.sh | 9 +- 80 files changed, 1930 insertions(+), 2229 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 create mode 100644 backend/sync_server/src/server/assets/index.html create mode 100644 backend/sync_server/src/server/index.rs create mode 100644 frontend/obsidian-plugin/src/utils/get-random-color.ts create mode 100644 frontend/obsidian-plugin/src/views/cursors/get-cursors-from-editor.ts create mode 100644 frontend/obsidian-plugin/src/views/cursors/local-cursor-update-listener.ts create mode 100644 frontend/obsidian-plugin/src/views/cursors/remote-cursor-theme.ts create mode 100644 frontend/obsidian-plugin/src/views/cursors/remote-cursor-widget.ts create mode 100644 frontend/obsidian-plugin/src/views/cursors/remote-cursors-plugin.ts 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/CreateDocumentVersion.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/CursorSpan.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/DocumentVersionWithoutContent.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/SerializedError.ts create mode 100644 frontend/sync-client/src/services/types/UpdateDocumentVersion.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/websocket-manager.ts create mode 100644 frontend/sync-client/src/utils/create-client-id.ts 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..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", - "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", - "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" @@ -662,6 +572,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b42b6fa04a440b495c8b04d0e71b707c585f83cb9cb28cf8cd0d976c315e31b4" dependencies = [ "powerfmt", + "serde", ] [[package]] @@ -699,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" @@ -767,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" @@ -815,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" @@ -942,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]] @@ -966,6 +849,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 +872,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 +1187,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 +1205,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "62f822373a4fe84d4bb149bf54e584a7f4abec90e072ed49cda0edea5b95471f" dependencies = [ "equivalent", - "hashbrown", + "hashbrown 0.15.2", "serde", ] @@ -1328,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" @@ -1362,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" @@ -1503,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" @@ -1546,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" @@ -1566,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" @@ -1607,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" @@ -1648,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" @@ -2059,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", - "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" @@ -2119,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" @@ -2152,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" @@ -2177,13 +1913,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 +2095,9 @@ dependencies = [ "futures-intrusive", "futures-io", "futures-util", - "hashbrown", + "hashbrown 0.15.2", "hashlink", - "indexmap", + "indexmap 2.7.0", "log", "memchr", "once_cell", @@ -2562,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", @@ -2578,9 +2341,9 @@ dependencies = [ "rand 0.9.0", "regex", "sanitize-filename", - "schemars", "serde", "serde_json", + "serde_with", "serde_yaml", "sqlx", "sync_lib", @@ -2589,6 +2352,7 @@ dependencies = [ "tower-http", "tracing", "tracing-subscriber", + "ts-rs", "uuid", ] @@ -2622,6 +2386,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" @@ -2712,6 +2485,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "35e7868883861bd0e56d9ac6efcaaca0d6d5d82a2a7ec8209ff492c07cf37b21" dependencies = [ "deranged", + "itoa", "num-conv", "powerfmt", "serde", @@ -2920,6 +2694,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/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/config-e2e.yml b/backend/config-e2e.yml index 17b745ea..5f2346d6 100644 --- a/backend/config-e2e.yml +++ b/backend/config-e2e.yml @@ -1,29 +1,26 @@ database: databases_directory_path: databases max_connections_per_vault: 12 - + cursor_timeout_seconds: 60 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/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" diff --git a/backend/sync_server/Cargo.toml b/backend/sync_server/Cargo.toml index a483ed5c..3ca2c75a 100644 --- a/backend/sync_server/Cargo.toml +++ b/backend/sync_server/Cargo.toml @@ -18,25 +18,23 @@ 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" +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.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..245109c2 --- /dev/null +++ b/backend/sync_server/src/app_state/cursors.rs @@ -0,0 +1,128 @@ +use core::time::Duration; +use std::{collections::HashMap, sync::Arc}; + +use tokio::sync::Mutex; + +use super::{ + database::models::{DeviceId, VaultId}, + websocket::{ + broadcasts::Broadcasts, + models::{ + ClientCursors, CursorPositionFromServer, CursorSpan, WebSocketServerMessage, + WebSocketServerMessageWithOrigin, + }, + }, +}; +use crate::config::database_config::DatabaseConfig; + +#[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, + user_name: String, + 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 { + user_name, + 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 { + 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 { + loop { + self.remove_expired_cursors().await; + tokio::time::sleep(Duration::from_secs(1)).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; + } + } + + 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)] +struct ClientCursorsWithTimeToLive { + client_cursors: ClientCursors, + last_updated: std::time::Instant, +} + +impl ClientCursorsWithTimeToLive { + fn new(client_cursors: ClientCursors) -> Self { + Self { + client_cursors, + last_updated: std::time::Instant::now(), + } + } + + pub fn is_expired(&self, ttl: Duration) -> bool { self.last_updated.elapsed() > 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..e995611e 100644 --- a/backend/sync_server/src/app_state/database/models.rs +++ b/backend/sync_server/src/app_state/database/models.rs @@ -1,10 +1,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,16 +26,20 @@ 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)] #[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, } @@ -53,10 +58,12 @@ impl From for DocumentVersionWithoutContent { } } -#[derive(Debug, Clone, Serialize, JsonSchema)] +#[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, 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..6bb4f4e1 --- /dev/null +++ b/backend/sync_server/src/app_state/websocket/models.rs @@ -0,0 +1,88 @@ +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, + + #[ts(as = "Option")] + 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>, +} + +#[derive(TS, Serialize, Clone, Debug)] +#[serde(rename_all = "camelCase")] +pub struct ClientCursors { + pub user_name: String, + 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)] +#[serde(rename_all = "camelCase", tag = "type")] +#[ts(export)] +pub enum WebSocketClientMessage { + Handshake(WebSocketHandshake), + CursorPositions(CursorPositionFromClient), +} + +#[derive(TS, Serialize, Clone, Debug)] +#[serde(rename_all = "camelCase", tag = "type")] +#[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..1e0dd243 --- /dev/null +++ b/backend/sync_server/src/app_state/websocket/utils.rs @@ -0,0 +1,80 @@ +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}, + }, + 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 { + if let Some(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) => { + let user = auth(state, handshake.token.trim(), vault_id)?; + Ok(AuthenticatedWebSocketHandshake { handshake, user }) + } + 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.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 { diff --git a/backend/sync_server/src/config/database_config.rs b/backend/sync_server/src/config/database_config.rs index ef26a09d..f1c92d9d 100644 --- a/backend/sync_server/src/config/database_config.rs +++ b/backend/sync_server/src/config/database_config.rs @@ -1,10 +1,14 @@ -use std::path::PathBuf; +use std::{path::PathBuf, time::Duration}; use log::debug; use serde::{Deserialize, Serialize}; +use serde_with::serde_as; -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, +}; +#[serde_with::serde_as] #[derive(Debug, Deserialize, Serialize, Clone)] pub struct DatabaseConfig { #[serde(default = "default_databases_directory_path")] @@ -12,6 +16,10 @@ pub struct DatabaseConfig { #[serde(default = "default_max_connections_per_vault")] pub max_connections_per_vault: u32, + + #[serde(default = "default_cursor_timeout", rename = "cursor_timeout_seconds")] + #[serde_as(as = "serde_with::DurationSeconds")] + pub cursor_timeout: Duration, } fn default_databases_directory_path() -> PathBuf { @@ -24,11 +32,17 @@ fn default_max_connections_per_vault() -> u32 { DEFAULT_MAX_CONNECTIONS_PER_VAULT } +fn default_cursor_timeout() -> Duration { + 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..df5a2844 100644 --- a/backend/sync_server/src/consts.rs +++ b/backend/sync_server/src/consts.rs @@ -1,8 +1,13 @@ +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: Duration = Duration::from_secs(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/errors.rs b/backend/sync_server/src/errors.rs index a16f7137..987c3011 100644 --- a/backend/sync_server/src/errors.rs +++ b/backend/sync_server/src/errors.rs @@ -1,15 +1,14 @@ 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; +use ts_rs::TS; #[derive(Error, Debug)] pub enum SyncServerError { @@ -45,8 +44,11 @@ impl SyncServerError { } } -#[derive(Debug, Clone, Serialize, JsonSchema)] +#[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, } @@ -90,41 +92,49 @@ 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, } } } -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.rs b/backend/sync_server/src/server.rs index 0fd5fa03..3f659c97 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; @@ -6,35 +6,27 @@ 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; 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 +43,21 @@ 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("/", get(index::index)) + .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 +95,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 +102,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/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/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}`" ))) diff --git a/backend/sync_server/src/server/create_document.rs b/backend/sync_server/src/server/create_document.rs index b9459df5..7018d8cf 100644 --- a/backend/sync_server/src/server/create_document.rs +++ b/backend/sync_server/src/server/create_document.rs @@ -1,34 +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, - broadcasts::VaultUpdate, - database::models::{ - DeviceId, 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, @@ -38,66 +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(user_agent): TypedHeader, + TypedHeader(device_id): TypedHeader, State(state): State, - TypedMultipart(axum_typed_multipart::TypedMultipart(request)): TypedMultipart< - CreateDocumentVersionMultipart, - >, -) -> Result, SyncServerError> { - internal_create_document( - user, - user_agent, - state, - vault_id, - request.document_id, - request.relative_path, - request.device_id, - 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(user_agent): 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, - user_agent, - state, - vault_id, - request.document_id, - request.relative_path, - request.device_id, - content_bytes, - ) - .await -} - -#[allow(clippy::too_many_arguments)] -async fn internal_create_document( - user: User, - user_agent: DeviceIdHeader, - state: AppState, - vault_id: VaultId, - document_id: Option, - relative_path: String, - device_id: Option, - content: Vec, + TypedMultipart(request): TypedMultipart, ) -> Result, SyncServerError> { let mut transaction = state .database @@ -105,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 @@ -130,17 +66,17 @@ 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, - device_id: user_agent.0, + device_id: device_id.0, }; state @@ -155,16 +91,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..5b7cd6ef 100644 --- a/backend/sync_server/src/server/delete_document.rs +++ b/backend/sync_server/src/server/delete_document.rs @@ -1,18 +1,15 @@ 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}; use crate::{ app_state::{ AppState, - broadcasts::VaultUpdate, database::models::{ DocumentId, DocumentVersionWithoutContent, StoredDocumentVersion, VaultId, }, @@ -22,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, @@ -38,7 +34,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 +65,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 +80,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/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/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) +} 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 26e6a398..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::{DeviceId, DocumentId, VaultUpdateId}; +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,41 +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, - pub device_id: Option, -} -#[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, - pub device_id: Option, } -#[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, - pub device_id: Option, -} -#[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, - pub device_id: Option, } -#[derive(Debug, Deserialize, JsonSchema)] +#[derive(TS, Debug, Deserialize)] #[serde(rename_all = "camelCase")] +#[ts(export)] pub struct DeleteDocumentVersion { pub relative_path: String, - pub device_id: Option, } 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 22eb38b0..a3ab25e1 100644 --- a/backend/sync_server/src/server/update_document.rs +++ b/backend/sync_server/src/server/update_document.rs @@ -1,34 +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, - broadcasts::VaultUpdate, - database::models::{DeviceId, 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, @@ -37,90 +32,34 @@ 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, }): Path, Extension(user): Extension, - TypedHeader(user_agent): TypedHeader, + TypedHeader(device_id): TypedHeader, State(state): State, - TypedMultipart(axum_typed_multipart::TypedMultipart(request)): TypedMultipart< - UpdateDocumentVersionMultipart, - >, -) -> Result, SyncServerError> { - internal_update_document( - user, - user_agent, - state, - vault_id, - document_id, - request.parent_version_id, - request.relative_path, - request.device_id, - 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(user_agent): 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, - user_agent, - state, - vault_id, - document_id, - request.parent_version_id, - request.relative_path, - request.device_id, - content_bytes, - ) - .await -} - -#[allow(clippy::too_many_arguments, clippy::too_many_lines)] -async fn internal_update_document( - user: User, - user_agent: DeviceIdHeader, - state: AppState, - vault_id: VaultId, - document_id: DocumentId, - parent_version_id: VaultUpdateId, - relative_path: String, - device_id: Option, - 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 @@ -160,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 @@ -215,7 +156,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 +171,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..e9dd8867 100644 --- a/backend/sync_server/src/server/websocket.rs +++ b/backend/sync_server/src/server/websocket.rs @@ -6,165 +6,176 @@ use axum::{ }, response::Response, }; -use futures::{ - sink::SinkExt, - stream::{SplitSink, StreamExt}, -}; -use log::{error, info, warn}; -use schemars::JsonSchema; -use serde::{Deserialize, Serialize}; +use futures::stream::StreamExt; +use log::{debug, info}; +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_authenticated_handshake, get_unseen_documents, send_update_over_websocket, + }, + }, }, - errors::{SyncServerError, server_error, unauthenticated_error}, + errors::{SyncServerError, client_error, server_error}, utils::normalize::normalize, }; -// This is required for aide to infer the path parameter types and names -#[derive(Deserialize, JsonSchema)] -pub struct WebsocketPathParams { +#[derive(Deserialize)] +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}"); + debug!("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, } +#[allow(clippy::too_many_lines)] async fn websocket( state: AppState, 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::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 - } else { - return Err(unauthenticated_error(anyhow::anyhow!( - "Failed to authenticate" - ))); - }; - - 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) + let authed_handshake = get_authenticated_handshake( + &state, + &vault_id, + websocket_receiver + .next() .await - .map_err(server_error) - } else { - state - .database - .get_latest_documents(&vault_id, None) - .await - .map_err(server_error) - }?; + .transpose() + .unwrap_or_default(), + )?; + + info!( + "WebSocket handshake successful for vault '{vault_id}' for '{}'", + authed_handshake.handshake.device_id + ); + + let mut broadcast_receiver = state.broadcasts.get_receiver(vault_id.clone()).await; send_update_over_websocket( - &WebsocketVaultUpdate { - documents, + &WebSocketServerMessage::VaultUpdate(WebSocketVaultUpdate { + documents: get_unseen_documents( + &state, + &vault_id, + authed_handshake.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 = authed_handshake.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() { + while let Ok(update) = broadcast_receiver.recv().await { + 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 = 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 { + while let Some(Ok(Message::Text(message))) = websocket_receiver.next().await { + let message: WebSocketClientMessage = serde_json::from_str(&message) + .context("Failed to parse WebSocket message from client") + .map_err(server_error)?; + + match message { + WebSocketClientMessage::Handshake(_) => { + return Err(client_error(anyhow::anyhow!( + "Unexpected handshake message" + ))); + } + WebSocketClientMessage::CursorPositions(cursors) => { + cursor_manager + .update_cursors( + vault_id_clone.clone(), + authed_handshake.user.name.clone(), + &device_id, + cursors.document_to_cursors, + ) + .await; + } + } + } + + Ok::<(), SyncServerError>(()) + }); 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 - .await - .context("Websocket send task failed") - .map_err(server_error)??; + let result: Result<(), SyncServerError> = (async { + send_task + .await + .context("WebSocket send task failed") + .map_err(client_error) + .and_then(|err| err)?; - recv_task - .await - .context("Websocket receive task failed") - .map_err(server_error)?; + receive_task + .await + .context("WebSocket receive task failed") + .map_err(client_error) + .and_then(|err| err)?; - 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) + Ok(()) + }) + .await; + + state + .cursors + .remove_cursors_of_device(&vault_id, &authed_handshake.handshake.device_id) + .await; + + if result.is_err() { + info!( + "WebSocket disconnected on vault '{vault_id}' for '{}'", + authed_handshake.handshake.device_id + ); + } + + result } diff --git a/frontend/obsidian-plugin/package.json b/frontend/obsidian-plugin/package.json index 95f41b60..c69b74ea 100644 --- a/frontend/obsidian-plugin/package.json +++ b/frontend/obsidian-plugin/package.json @@ -1,39 +1,39 @@ { - "name": "vault-link-obsidian-plugin", - "version": "0.3.15", - "description": "This is a sample plugin for Obsidian (https://obsidian.md)", - "main": "main.js", - "scripts": { - "dev": "webpack watch --mode development", - "build": "webpack --mode production", - "test": "jest", - "version": "node version-bump.mjs" - }, - "keywords": [], - "author": "", - "license": "MIT", - "devDependencies": { - "@types/jest": "^29.5.14", - "@types/node": "^22.15.27", - "css-loader": "^7.1.2", - "date-fns": "^4.1.0", - "file-loader": "^6.2.0", - "fs-extra": "^11.3.0", - "jest": "^29.7.0", - "mini-css-extract-plugin": "^2.9.2", - "obsidian": "1.8.7", - "resolve-url-loader": "^5.0.0", - "sass": "^1.89.0", - "sass-loader": "^16.0.5", - "sync-client": "file:../sync-client", - "terser-webpack-plugin": "^5.3.14", - "ts-jest": "^29.3.4", - "ts-loader": "^9.5.2", - "tslib": "2.8.1", - "typescript": "5.8.3", - "url": "^0.11.4", - "virtual-scroller": "^1.13.1", - "webpack": "^5.98.0", - "webpack-cli": "^6.0.1" - } -} + "name": "vault-link-obsidian-plugin", + "version": "0.3.15", + "description": "This is a sample plugin for Obsidian (https://obsidian.md)", + "main": "main.js", + "scripts": { + "dev": "webpack watch --mode development", + "build": "webpack --mode production", + "test": "jest", + "version": "node version-bump.mjs" + }, + "keywords": [], + "author": "", + "license": "MIT", + "devDependencies": { + "@types/jest": "^29.5.14", + "@types/node": "^22.15.30", + "css-loader": "^7.1.2", + "date-fns": "^4.1.0", + "file-loader": "^6.2.0", + "fs-extra": "^11.3.0", + "jest": "^29.7.0", + "mini-css-extract-plugin": "^2.9.2", + "obsidian": "1.8.7", + "resolve-url-loader": "^5.0.0", + "sass": "^1.89.1", + "sass-loader": "^16.0.5", + "sync-client": "file:../sync-client", + "terser-webpack-plugin": "^5.3.14", + "ts-jest": "^29.3.4", + "ts-loader": "^9.5.2", + "tslib": "2.8.1", + "typescript": "5.8.3", + "url": "^0.11.4", + "virtual-scroller": "^1.13.1", + "webpack": "^5.99.9", + "webpack-cli": "^6.0.1" + } +} \ No newline at end of file diff --git a/frontend/obsidian-plugin/src/obsidian-file-system.ts b/frontend/obsidian-plugin/src/obsidian-file-system.ts index 9905b036..adf78a16 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 "./views/cursors/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-random-color.ts b/frontend/obsidian-plugin/src/utils/get-random-color.ts new file mode 100644 index 00000000..5b2d33dc --- /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)}, 55%, 55%)`; // HSL color +} diff --git a/frontend/obsidian-plugin/src/vault-link-plugin.ts b/frontend/obsidian-plugin/src/vault-link-plugin.ts index e889bf9b..315e2d19 100644 --- a/frontend/obsidian-plugin/src/vault-link-plugin.ts +++ b/frontend/obsidian-plugin/src/vault-link-plugin.ts @@ -1,24 +1,36 @@ import type { Editor, + EventRef, MarkdownFileInfo, - MarkdownView, TAbstractFile, + Workspace, WorkspaceLeaf } from "obsidian"; +import type { 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/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: (() => void)[] = []; + private readonly disposables: (() => unknown)[] = []; + private settingsTab: SyncSettingsTab | undefined; private client!: SyncClient; private readonly rateLimitedUpdatesPerFile = new Map< @@ -61,18 +73,36 @@ 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.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( HistoryView.ICON, "Open VaultLink events", async (_: MouseEvent) => this.activateView(HistoryView.TYPE) ); + this.addRibbonIcon( LogsView.ICON, "Open VaultLink logs", @@ -181,7 +211,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/cursors/get-cursors-from-editor.ts b/frontend/obsidian-plugin/src/views/cursors/get-cursors-from-editor.ts new file mode 100644 index 00000000..f5ea0a85 --- /dev/null +++ b/frontend/obsidian-plugin/src/views/cursors/get-cursors-from-editor.ts @@ -0,0 +1,17 @@ +import type { Editor } from "obsidian"; +import { lineAndColumnToPosition } from "../../utils/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) + })); +} 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..99a9828d --- /dev/null +++ b/frontend/obsidian-plugin/src/views/cursors/local-cursor-update-listener.ts @@ -0,0 +1,58 @@ +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; + 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); + } + + public dispose(): void { + clearInterval(this.eventHandle); + } + + private updateAllCursors(): void { + const currentCursors = this.getAllCursors(); + if ( + JSON.stringify(this.lastCursorState) === + JSON.stringify(currentCursors) + ) { + return; + } + this.lastCursorState = currentCursors; + this.client + .updateLocalCursors(currentCursors) + .catch((error: unknown) => { + this.client.logger.error( + `Failed to update local cursors: ${error}` + ); + }); + } + + 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; + } +} 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/cursors/remote-cursor-widget.ts b/frontend/obsidian-plugin/src/views/cursors/remote-cursor-widget.ts new file mode 100644 index 00000000..e3273484 --- /dev/null +++ b/frontend/obsidian-plugin/src/views/cursors/remote-cursor-widget.ts @@ -0,0 +1,46 @@ +import { AnnotationType, Annotation, RangeSet, Range } from "@codemirror/state"; +import { + ViewUpdate, + ViewPlugin, + Decoration, + WidgetType +} from "@codemirror/view"; + +import type { PluginValue, DecorationSet, EditorView } 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: "selection-caret", + attr: { + style: `background-color: ${this.color}; border-color: ${this.color}` + } + }, + (span) => { + span.createEl("div", { + cls: "stick" + }); + span.createEl("div", { + cls: "dot" + }); + span.createEl("div", { + cls: "info", + text: this.name + }); + } + ); + } + + 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..e7797d1a --- /dev/null +++ b/frontend/obsidian-plugin/src/views/cursors/remote-cursors-plugin.ts @@ -0,0 +1,134 @@ +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): void { + cursors = clients.flatMap((client) => { + 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 + .getLeavesOfType("markdown") + .map((leaf) => leaf.view) + .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({ + effects: [forceUpdate.of(null)] + }); + }); +} 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/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/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/package-lock.json b/frontend/package-lock.json index 0990a050..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": { @@ -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" @@ -630,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": { @@ -655,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": { @@ -692,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": { @@ -712,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": { @@ -1620,76 +1625,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", @@ -1857,9 +1792,9 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "22.15.27", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.15.27.tgz", - "integrity": "sha512-5fF+eu5mwihV2BeVtX5vijhdaZOfkQTATrePEaXTcKqI16LhJ7gi2/Vhd9OZM0UojcdmiOCVg5rrax+i1MdoQQ==", + "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": { @@ -1901,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", @@ -1925,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" } @@ -1941,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": { @@ -1965,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" @@ -1983,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" }, @@ -2008,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": { @@ -2022,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", @@ -2075,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" @@ -2099,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": { @@ -2375,15 +2351,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 +2420,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 +2480,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 +2841,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 +2968,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 +3116,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" @@ -3417,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", @@ -3671,6 +3619,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 +4095,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 +4182,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 +5000,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 +5432,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": { @@ -5595,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": { @@ -5676,82 +5594,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 +5755,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 +5850,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 +6167,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" @@ -6493,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": { @@ -7276,6 +7111,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", @@ -7286,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" @@ -7366,12 +7202,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", @@ -7491,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", @@ -7515,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", @@ -7684,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": { @@ -7814,12 +7645,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 +7668,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" @@ -7867,7 +7693,7 @@ "license": "MIT", "devDependencies": { "@types/jest": "^29.5.14", - "@types/node": "^22.15.27", + "@types/node": "^22.15.30", "css-loader": "^7.1.2", "date-fns": "^4.1.0", "file-loader": "^6.2.0", @@ -7876,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", @@ -7886,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" } }, @@ -7895,21 +7721,19 @@ "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" }, "devDependencies": { "@types/jest": "^29.5.14", - "@types/node": "^22.15.27", + "@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.2" @@ -7945,14 +7769,14 @@ "test-client": "dist/cli.js" }, "devDependencies": { - "@types/node": "^22.15.27", + "@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 b13942dd..4c4b2ca0 100644 --- a/frontend/sync-client/package.json +++ b/frontend/sync-client/package.json @@ -15,23 +15,21 @@ "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" }, "devDependencies": { "@types/jest": "^29.5.14", - "@types/node": "^22.15.27", + "@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.2" } -} +} \ No newline at end of file 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/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/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 741aa012..5ac81d5b 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"; // 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 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; @@ -19,47 +24,28 @@ export interface CheckConnectionResult { export class SyncService { private static readonly NETWORK_RETRY_INTERVAL_MS = 1000; - private client: Client; - private pingClient: Client; + private readonly client: typeof globalThis.fetch; + private readonly 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 = async (...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 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 { + private static formatError(error: SerializedError): string { let result = error.message; if (error.causes.length > 0) { const causes = error.causes.join(", "); @@ -77,47 +63,39 @@ 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) { 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( - "/vaults/{vault_id}/documents", - { - params: { - path: { - vault_id: vaultName - }, - header: { - "device-id": this.deviceIdHeader - } - }, - // 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()) as // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion + | SerializedError + | DocumentVersionWithoutContent; + + 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; }); } @@ -131,9 +109,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}` @@ -141,39 +117,35 @@ 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( - "/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.deviceIdHeader - } - }, - // 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()) as // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion + | SerializedError + | DocumentUpdateResponse; + + 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; }); } @@ -183,39 +155,39 @@ 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.deviceIdHeader - } - }, - - body: { - relativePath, - deviceId: this.deviceId + 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()) as // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion + | SerializedError + | DocumentVersionWithoutContent; + + 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; }); } @@ -223,100 +195,77 @@ 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()) as SerializedError | DocumentVersion; // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion + + 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()) as // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion + | SerializedError + | FetchLatestDocumentsResponse; - 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()) as PingResponse | SerializedError; // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion - 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, @@ -336,29 +285,17 @@ 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 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, + authorization: `Bearer ${this.settings.getSettings().token}` + }; } private async withRetries(fn: () => Promise): Promise { 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..9bf8739f --- /dev/null +++ b/frontend/sync-client/src/services/types/ClientCursors.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 { CursorSpan } from "./CursorSpan"; + +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 new file mode 100644 index 00000000..d4bd376b --- /dev/null +++ b/frontend/sync-client/src/services/types/CreateDocumentVersion.ts @@ -0,0 +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[]; +} 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..d33c0c8e --- /dev/null +++ b/frontend/sync-client/src/services/types/CursorPositionFromClient.ts @@ -0,0 +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>; +} 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..2556b748 --- /dev/null +++ b/frontend/sync-client/src/services/types/CursorPositionFromServer.ts @@ -0,0 +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[]; +} 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..5bc2542e --- /dev/null +++ b/frontend/sync-client/src/services/types/CursorSpan.ts @@ -0,0 +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; +} 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..9edb09ed --- /dev/null +++ b/frontend/sync-client/src/services/types/DeleteDocumentVersion.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. + +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 new file mode 100644 index 00000000..f0ed7abf --- /dev/null +++ b/frontend/sync-client/src/services/types/DocumentUpdateResponse.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. +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..2076d296 --- /dev/null +++ b/frontend/sync-client/src/services/types/DocumentVersion.ts @@ -0,0 +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; +} 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..cb23f6a5 --- /dev/null +++ b/frontend/sync-client/src/services/types/DocumentVersionWithoutContent.ts @@ -0,0 +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; +} 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..67c19b2d --- /dev/null +++ b/frontend/sync-client/src/services/types/FetchLatestDocumentsResponse.ts @@ -0,0 +1,13 @@ +// 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 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 new file mode 100644 index 00000000..b0d993f2 --- /dev/null +++ b/frontend/sync-client/src/services/types/PingResponse.ts @@ -0,0 +1,16 @@ +// 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 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 new file mode 100644 index 00000000..c0979c5a --- /dev/null +++ b/frontend/sync-client/src/services/types/SerializedError.ts @@ -0,0 +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[]; +} 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..bc3d54e5 --- /dev/null +++ b/frontend/sync-client/src/services/types/UpdateDocumentVersion.ts @@ -0,0 +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[]; +} 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..e7de2cf3 --- /dev/null +++ b/frontend/sync-client/src/services/types/WebSocketClientMessage.ts @@ -0,0 +1,7 @@ +// 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..068b3505 --- /dev/null +++ b/frontend/sync-client/src/services/types/WebSocketHandshake.ts @@ -0,0 +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; +} 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..8ebf8911 --- /dev/null +++ b/frontend/sync-client/src/services/types/WebSocketServerMessage.ts @@ -0,0 +1,7 @@ +// 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..ad50c25d --- /dev/null +++ b/frontend/sync-client/src/services/types/WebSocketVaultUpdate.ts @@ -0,0 +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; +} 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..285d51f9 --- /dev/null +++ b/frontend/sync-client/src/services/websocket-manager.ts @@ -0,0 +1,209 @@ +import type { Database } from "../persistence/database"; +import type { Logger } from "../tracing/logger"; +import type { Settings, SyncSettings } from "../persistence/settings"; +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)[] = []; + private readonly remoteCursorsUpdateListeners: (( + cursors: ClientCursors[] + ) => unknown)[] = []; + + private refreshWebSocketInterval: NodeJS.Timeout | undefined; + + private webSocket: WebSocket | undefined; + + private readonly webSocketFactoryImplementation: typeof globalThis.WebSocket; + + 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 addRemoteCursorsUpdateListener( + listener: (cursors: ClientCursors[]) => void + ): void { + this.remoteCursorsUpdateListeners.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}`); + } + } + + 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(); + } 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}` + ); + } + // 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)}` + ); + this.remoteCursorsUpdateListeners.forEach((listener) => { + listener( + message.clients.filter( + (client) => client.deviceId !== this.deviceId + ) + ); + }); + } 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(); + }); + + const 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(); + }); + }; + } + + 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..6d51212e 100644 --- a/frontend/sync-client/src/sync-client.ts +++ b/frontend/sync-client/src/sync-client.ts @@ -15,9 +15,12 @@ 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"; +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; @@ -29,6 +32,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 +72,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 +111,6 @@ export class SyncClient { await rateLimitedSave(state); } ); - const deviceId = uuidv4(); const connectionStatus = new ConnectionStatus(settings, logger); const syncService = new SyncService( @@ -121,6 +127,7 @@ export class SyncClient { fs, nativeLineEndings ); + const unrestrictedSyncer = new UnrestrictedSyncer( logger, database, @@ -129,6 +136,7 @@ export class SyncClient { fileOperations, history ); + const syncer = new Syncer( deviceId, logger, @@ -136,7 +144,15 @@ export class SyncClient { settings, syncService, fileOperations, - unrestrictedSyncer, + unrestrictedSyncer + ); + + const webSocketManager = new WebSocketManager( + deviceId, + logger, + database, + settings, + syncer, webSocket ); @@ -146,6 +162,7 @@ export class SyncClient { database, syncer, syncService, + webSocketManager, logger, connectionStatus ); @@ -160,7 +177,7 @@ export class SyncClient { return { isSuccessful: server.isSuccessful, serverMessage: server.message, - isWebSocketConnected: this.syncer.isWebSocketConnected + isWebSocketConnected: this.webSocketManager.isWebSocketConnected }; } @@ -179,7 +196,7 @@ export class SyncClient { } public stop(): void { - this.syncer.stop(); + this.webSocketManager.stop(); } public async waitAndStop(): Promise { @@ -194,6 +211,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 +247,7 @@ export class SyncClient { } public addWebSocketStatusChangeListener(listener: () => void): void { - this.syncer.addWebSocketStatusChangeListener(listener); + this.webSocketManager.addWebSocketStatusChangeListener(listener); } public async syncLocallyCreatedFile( @@ -257,6 +275,18 @@ export class SyncClient { }); } + public async updateLocalCursors( + documentToCursors: Record + ): Promise { + this.webSocketManager.updateLocalCursors({ documentToCursors }); + } + + public addRemoteCursorsUpdateListener( + listener: (cursors: ClientCursors[]) => void + ): void { + this.webSocketManager.addRemoteCursorsUpdateListener(listener); + } + public getDocumentSyncingStatus( relativePath: RelativePath ): DocumentUpdateStatus { diff --git a/frontend/sync-client/src/sync-operations/syncer.ts b/frontend/sync-client/src/sync-operations/syncer.ts index e141ce9d..30e012d9 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"; import type { Settings, SyncSettings } from "../persistence/settings"; import type { FileOperations } from "../file-operations/file-operations"; import { findMatchingFile } from "../utils/find-matching-file"; @@ -17,27 +16,16 @@ import type { UnrestrictedSyncer } from "./unrestricted-syncer"; 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; -} +import type { DocumentVersionWithoutContent } from "../services/types/DocumentVersionWithoutContent"; 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,106 +252,10 @@ 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( - remoteVersion: components["schemas"]["DocumentVersionWithoutContent"] + public async syncRemotelyUpdatedFile( + 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 b9780939..0d0f45ef 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"; 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 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[]; @@ -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 = { 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})`; +} 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) {} diff --git a/frontend/test-client/package.json b/frontend/test-client/package.json index b5910d9f..90e82ea3 100644 --- a/frontend/test-client/package.json +++ b/frontend/test-client/package.json @@ -11,14 +11,14 @@ "test": "jest" }, "devDependencies": { - "@types/node": "^22.15.27", + "@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" } -} +} \ No newline at end of file diff --git a/scripts/update-api-types.sh b/scripts/update-api-types.sh index d9f39566..aea8a890 100755 --- a/scripts/update-api-types.sh +++ b/scripts/update-api-types.sh @@ -2,7 +2,10 @@ set -e -./scripts/utils/wait-for-server.sh +rm -rf backend/sync_server/bindings -npm install -g openapi-typescript -openapi-typescript http://localhost:3000/api.json --output frontend/sync-client/src/services/types.ts +cd backend +cargo test export_bindings +cd - + +cp -r backend/sync_server/bindings/* frontend/sync-client/src/services/types/